feat: scope runtime bindings by workspace
This commit is contained in:
@@ -5,12 +5,10 @@ use std::{fs, io};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::hosts::RemoteRuntimeConfig;
|
|
||||||
use crate::identity::WorkspaceIdentity;
|
use crate::identity::WorkspaceIdentity;
|
||||||
use crate::server::{AuthConfig, ServerConfig};
|
use crate::server::{AuthConfig, ServerConfig};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
|
|
||||||
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
|
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
|
||||||
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
||||||
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
|
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
|
||||||
@@ -51,31 +49,6 @@ fn default_browser_public_url() -> String {
|
|||||||
DEFAULT_BROWSER_PUBLIC_URL.to_string()
|
DEFAULT_BROWSER_PUBLIC_URL.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct BackendRuntimesConfigFile {
|
|
||||||
#[serde(default)]
|
|
||||||
pub runtimes: WorkspaceBackendRuntimesConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendRuntimesConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub remote: Vec<RemoteRuntimeConfigFile>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct RemoteRuntimeConfigFile {
|
|
||||||
pub id: String,
|
|
||||||
pub endpoint: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub token_ref: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ResolvedWorkspaceBackendConfig {
|
pub struct ResolvedWorkspaceBackendConfig {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
@@ -124,80 +97,11 @@ impl ServerHostConfigFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BackendRuntimesConfigFile {
|
|
||||||
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
|
||||||
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn default_path() -> Option<PathBuf> {
|
|
||||||
manifest::paths::config_dir().map(Self::path_for_config_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_default() -> Result<Self> {
|
|
||||||
match Self::default_path() {
|
|
||||||
Some(path) => Self::load_from_path(path),
|
|
||||||
None => Ok(Self::default()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_from_config_dir(config_dir: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
Self::load_from_path(Self::path_for_config_dir(config_dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
let path = path.as_ref();
|
|
||||||
match fs::read_to_string(path) {
|
|
||||||
Ok(raw) => Self::parse_str(&raw, path),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_default(&self) -> Result<PathBuf> {
|
|
||||||
let path = Self::default_path().ok_or_else(|| {
|
|
||||||
Error::Config(
|
|
||||||
"YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME is required to write Backend runtimes config"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
self.write_to_path(&path)?;
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_to_config_dir(&self, config_dir: impl AsRef<Path>) -> Result<()> {
|
|
||||||
self.write_to_path(Self::path_for_config_dir(config_dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<()> {
|
|
||||||
let path = path.as_ref();
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let raw = toml::to_string_pretty(self).map_err(|error| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"failed to serialize Backend runtimes config: {error}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
fs::write(path, raw)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
toml::from_str(raw).map_err(|error| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"failed to parse Backend runtimes config `{}`: {error}",
|
|
||||||
path.as_ref().display()
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResolvedWorkspaceBackendConfig {
|
impl ResolvedWorkspaceBackendConfig {
|
||||||
pub fn local_dev(
|
pub fn local_dev(
|
||||||
workspace_root: impl AsRef<Path>,
|
workspace_root: impl AsRef<Path>,
|
||||||
identity: WorkspaceIdentity,
|
identity: WorkspaceIdentity,
|
||||||
host_config: &ServerHostConfigFile,
|
host_config: &ServerHostConfigFile,
|
||||||
runtime_config: &BackendRuntimesConfigFile,
|
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let workspace_root = workspace_root.as_ref();
|
let workspace_root = workspace_root.as_ref();
|
||||||
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
|
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
|
||||||
@@ -208,12 +112,7 @@ impl ResolvedWorkspaceBackendConfig {
|
|||||||
server.database_path = database_path.clone();
|
server.database_path = database_path.clone();
|
||||||
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
|
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
|
||||||
server.max_records = DEFAULT_MAX_RECORDS;
|
server.max_records = DEFAULT_MAX_RECORDS;
|
||||||
server.remote_runtime_sources = runtime_config
|
server.remote_runtime_sources = Vec::new();
|
||||||
.runtimes
|
|
||||||
.remote
|
|
||||||
.iter()
|
|
||||||
.map(resolve_remote_runtime)
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
server.auth = AuthConfig::Passkey {
|
server.auth = AuthConfig::Passkey {
|
||||||
rp_id: browser_rp_id,
|
rp_id: browser_rp_id,
|
||||||
origin: browser_public_url.clone(),
|
origin: browser_public_url.clone(),
|
||||||
@@ -252,26 +151,6 @@ fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
|||||||
Ok(trimmed.to_string())
|
Ok(trimmed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve_remote_runtime(
|
|
||||||
config: &RemoteRuntimeConfigFile,
|
|
||||||
) -> Result<RemoteRuntimeConfig> {
|
|
||||||
if let Some(token_ref) = config.token_ref.as_deref() {
|
|
||||||
return Err(Error::Config(format!(
|
|
||||||
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet",
|
|
||||||
config.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(RemoteRuntimeConfig::new(
|
|
||||||
config.id.clone(),
|
|
||||||
config
|
|
||||||
.display_name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| config.id.clone()),
|
|
||||||
config.endpoint.clone(),
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
|
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
|
||||||
let value = normalize_required_string("browser.public_url", value)?;
|
let value = normalize_required_string("browser.public_url", value)?;
|
||||||
let url = Url::parse(&value).map_err(|error| {
|
let url = Url::parse(&value).map_err(|error| {
|
||||||
@@ -314,22 +193,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolved_with_runtimes(
|
fn resolved() -> ResolvedWorkspaceBackendConfig {
|
||||||
runtimes: &BackendRuntimesConfigFile,
|
|
||||||
) -> ResolvedWorkspaceBackendConfig {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
ResolvedWorkspaceBackendConfig::local_dev(
|
ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
identity(),
|
identity(),
|
||||||
&ServerHostConfigFile::default(),
|
&ServerHostConfigFile::default(),
|
||||||
runtimes,
|
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_settings_resolve_without_a_repository_file() {
|
fn default_settings_resolve_without_a_repository_file() {
|
||||||
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default());
|
let resolved = resolved();
|
||||||
|
|
||||||
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
|
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
|
||||||
let AuthConfig::Passkey {
|
let AuthConfig::Passkey {
|
||||||
@@ -354,7 +230,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn backend_base_url_is_explicit_and_normalized() {
|
fn backend_base_url_is_explicit_and_normalized() {
|
||||||
let listen = "127.0.0.1:48787".parse().unwrap();
|
let listen = "127.0.0.1:48787".parse().unwrap();
|
||||||
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default())
|
let resolved = resolved()
|
||||||
.with_listen(listen)
|
.with_listen(listen)
|
||||||
.with_backend_base_url("http://127.0.0.1:48787/");
|
.with_backend_base_url("http://127.0.0.1:48787/");
|
||||||
|
|
||||||
@@ -376,7 +252,6 @@ mod tests {
|
|||||||
tempfile::tempdir().unwrap().path(),
|
tempfile::tempdir().unwrap().path(),
|
||||||
identity(),
|
identity(),
|
||||||
&host_config,
|
&host_config,
|
||||||
&BackendRuntimesConfigFile::default(),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -407,7 +282,6 @@ mod tests {
|
|||||||
tempfile::tempdir().unwrap().path(),
|
tempfile::tempdir().unwrap().path(),
|
||||||
identity(),
|
identity(),
|
||||||
&host_config,
|
&host_config,
|
||||||
&BackendRuntimesConfigFile::default(),
|
|
||||||
);
|
);
|
||||||
let error = match result {
|
let error = match result {
|
||||||
Ok(_) => panic!("expected {value} to be rejected"),
|
Ok(_) => panic!("expected {value} to be rejected"),
|
||||||
@@ -446,92 +320,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_runtimes_config_loads_from_config_dir() {
|
fn local_host_config_does_not_supply_runtime_authority() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let resolved = resolved();
|
||||||
let config = BackendRuntimesConfigFile {
|
assert!(resolved.server.remote_runtime_sources.is_empty());
|
||||||
runtimes: WorkspaceBackendRuntimesConfig {
|
|
||||||
remote: vec![RemoteRuntimeConfigFile {
|
|
||||||
id: "arc".to_string(),
|
|
||||||
endpoint: "http://127.0.0.1:38800".to_string(),
|
|
||||||
display_name: Some("arc".to_string()),
|
|
||||||
token_ref: None,
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
config.write_to_config_dir(dir.path()).unwrap();
|
|
||||||
let loaded = BackendRuntimesConfigFile::load_from_config_dir(dir.path()).unwrap();
|
|
||||||
assert_eq!(loaded, config);
|
|
||||||
assert_eq!(
|
|
||||||
BackendRuntimesConfigFile::path_for_config_dir(dir.path()),
|
|
||||||
dir.path().join("runtimes.toml")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_runtimes_config_is_the_only_runtime_source() {
|
|
||||||
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[[runtimes.remote]]
|
|
||||||
id = "arc"
|
|
||||||
endpoint = "http://xdg.example.test"
|
|
||||||
display_name = "xdg arc"
|
|
||||||
"#,
|
|
||||||
"runtimes.toml",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = resolved_with_runtimes(&runtime_config);
|
|
||||||
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
|
|
||||||
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.remote_runtime_sources[0].base_url.as_str(),
|
|
||||||
"http://xdg.example.test"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_value_field_is_not_in_runtime_schema() {
|
|
||||||
let error = BackendRuntimesConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[[runtimes.remote]]
|
|
||||||
id = "remote"
|
|
||||||
endpoint = "http://127.0.0.1:8790"
|
|
||||||
token = "secret"
|
|
||||||
"#,
|
|
||||||
"runtimes.toml",
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("unknown field"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_ref_fails_closed_until_secret_resolution_exists() {
|
|
||||||
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[[runtimes.remote]]
|
|
||||||
id = "remote"
|
|
||||||
endpoint = "http://127.0.0.1:8790"
|
|
||||||
token_ref = "local:remote-token"
|
|
||||||
"#,
|
|
||||||
"runtimes.toml",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let error = match ResolvedWorkspaceBackendConfig::local_dev(
|
|
||||||
tempfile::tempdir().unwrap().path(),
|
|
||||||
identity(),
|
|
||||||
&ServerHostConfigFile::default(),
|
|
||||||
&runtime_config,
|
|
||||||
) {
|
|
||||||
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
|
|
||||||
Err(error) => error,
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
error
|
|
||||||
.to_string()
|
|
||||||
.contains("secret ref resolution is not implemented"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ use worker_runtime::retention::{
|
|||||||
WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory,
|
WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||||
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
|
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
|
||||||
const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host";
|
const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host";
|
||||||
const MAX_DIAGNOSTICS: usize = 16;
|
const MAX_DIAGNOSTICS: usize = 16;
|
||||||
|
|||||||
@@ -433,15 +433,21 @@ CREATE TABLE ticket_worker_assignments (
|
|||||||
(principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0)
|
(principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
CREATE TABLE trusted_runtime_records (
|
CREATE TABLE workspace_runtime_bindings (
|
||||||
runtime_id TEXT PRIMARY KEY,
|
workspace_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
base_url TEXT NOT NULL,
|
base_url TEXT NOT NULL,
|
||||||
public_key TEXT NOT NULL,
|
public_key TEXT,
|
||||||
|
public_key_fingerprint TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
revoked_at TEXT
|
revoked_at TEXT,
|
||||||
, workspace_id TEXT REFERENCES workspaces(workspace_id) ON DELETE RESTRICT);
|
PRIMARY KEY (workspace_id, runtime_id),
|
||||||
|
UNIQUE (workspace_id, public_key_fingerprint),
|
||||||
|
CHECK ((public_key IS NULL) = (public_key_fingerprint IS NULL)),
|
||||||
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
CREATE TABLE typed_ticket_artifacts (
|
CREATE TABLE typed_ticket_artifacts (
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
||||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
||||||
@@ -651,11 +657,12 @@ CREATE TABLE worker_diagnostics_archives (
|
|||||||
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
|
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
|
||||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
|
||||||
CREATE TABLE worker_mutation_source_proof_jtis (
|
CREATE TABLE worker_mutation_source_proof_jtis (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
runtime_id TEXT NOT NULL,
|
runtime_id TEXT NOT NULL,
|
||||||
jti TEXT NOT NULL,
|
jti TEXT NOT NULL,
|
||||||
expires_at INTEGER NOT NULL,
|
expires_at INTEGER NOT NULL,
|
||||||
consumed_at TEXT NOT NULL,
|
consumed_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (runtime_id, jti)
|
PRIMARY KEY (workspace_id, runtime_id, jti)
|
||||||
);
|
);
|
||||||
CREATE TABLE worker_orphan_diagnostics (
|
CREATE TABLE worker_orphan_diagnostics (
|
||||||
diagnostic_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
diagnostic_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||||
@@ -857,8 +864,8 @@ CREATE INDEX idx_ticket_worker_assignments_principal
|
|||||||
ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC);
|
ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC);
|
||||||
CREATE INDEX idx_ticket_worker_assignments_ticket
|
CREATE INDEX idx_ticket_worker_assignments_ticket
|
||||||
ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC);
|
ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC);
|
||||||
CREATE INDEX idx_trusted_runtime_records_workspace
|
CREATE INDEX idx_workspace_runtime_bindings_workspace
|
||||||
ON trusted_runtime_records(workspace_id, revoked_at, runtime_id);
|
ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id);
|
||||||
CREATE INDEX idx_typed_ticket_relations_workspace_target
|
CREATE INDEX idx_typed_ticket_relations_workspace_target
|
||||||
ON typed_ticket_relations(workspace_id, target, at DESC);
|
ON typed_ticket_relations(workspace_id, target, at DESC);
|
||||||
CREATE INDEX idx_typed_tickets_workspace_state_updated
|
CREATE INDEX idx_typed_tickets_workspace_state_updated
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ pub use authority::{
|
|||||||
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
||||||
WorkspaceAuthority,
|
WorkspaceAuthority,
|
||||||
};
|
};
|
||||||
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
|
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
|
||||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||||
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
||||||
pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
|
pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
|
||||||
@@ -118,6 +118,8 @@ pub enum Error {
|
|||||||
WorkspacePermissionDenied(String),
|
WorkspacePermissionDenied(String),
|
||||||
#[error("Workspace config update conflict: {0}")]
|
#[error("Workspace config update conflict: {0}")]
|
||||||
WorkspaceConfigConflict(String),
|
WorkspaceConfigConflict(String),
|
||||||
|
#[error("Runtime binding conflict: {0}")]
|
||||||
|
RuntimeBindingConflict(String),
|
||||||
#[error("Repository conflict: {0}")]
|
#[error("Repository conflict: {0}")]
|
||||||
RepositoryConflict(String),
|
RepositoryConflict(String),
|
||||||
#[error("Registry inconsistency: {0}")]
|
#[error("Registry inconsistency: {0}")]
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
|
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
|
||||||
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
||||||
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
|
use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding};
|
||||||
use yoi_workspace_server::{
|
use yoi_workspace_server::{
|
||||||
BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig,
|
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
||||||
ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -315,40 +315,47 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
let public_key = public_key
|
let public_key = public_key
|
||||||
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
|
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
|
||||||
decode_public_key(&public_key)?;
|
decode_public_key(&public_key)?;
|
||||||
ensure_trusted_runtime_replace_allowed(&store, &runtime_id, replace)?;
|
|
||||||
if let Some(existing) = store
|
|
||||||
.list_trusted_runtimes(true)?
|
|
||||||
.into_iter()
|
|
||||||
.find(|runtime| runtime.runtime_id == runtime_id)
|
|
||||||
{
|
|
||||||
if existing.workspace_id.as_deref() != Some(workspace_id.as_str()) {
|
|
||||||
return Err(Box::new(CliError(format!(
|
|
||||||
"runtime `{runtime_id}` is already assigned to Workspace `{}` and cannot be reparented",
|
|
||||||
existing.workspace_id.as_deref().unwrap_or("unassigned")
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
store.upsert_trusted_runtime(&TrustedRuntimeRecord {
|
let outcome = store.upsert_workspace_runtime_binding(
|
||||||
|
WorkspaceRuntimeBinding {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
runtime_id: runtime_id.clone(),
|
runtime_id: runtime_id.clone(),
|
||||||
workspace_id: Some(workspace_id.clone()),
|
|
||||||
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
|
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
|
||||||
base_url,
|
base_url,
|
||||||
public_key,
|
public_key: Some(public_key),
|
||||||
|
public_key_fingerprint: None,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
})?;
|
},
|
||||||
println!("trusted_runtime_id={runtime_id}");
|
replace,
|
||||||
|
)?;
|
||||||
|
println!("workspace_id={workspace_id}");
|
||||||
|
println!("runtime_id={runtime_id}");
|
||||||
|
println!(
|
||||||
|
"result={}",
|
||||||
|
match outcome {
|
||||||
|
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Created =>
|
||||||
|
"created",
|
||||||
|
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged =>
|
||||||
|
"unchanged",
|
||||||
|
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Replaced =>
|
||||||
|
"replaced",
|
||||||
|
}
|
||||||
|
);
|
||||||
println!("server_db={}", database_path.display());
|
println!("server_db={}", database_path.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
"list" => {
|
"list" => {
|
||||||
|
let mut workspace_id = None;
|
||||||
let mut json = false;
|
let mut json = false;
|
||||||
let mut include_revoked = false;
|
let mut include_revoked = false;
|
||||||
while let Some(arg) = args.pop_front() {
|
while let Some(arg) = args.pop_front() {
|
||||||
let (flag, inline_value) = split_flag_value(arg)?;
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
match flag.as_str() {
|
match flag.as_str() {
|
||||||
|
"--workspace-id" => {
|
||||||
|
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
|
||||||
|
}
|
||||||
"--json" => {
|
"--json" => {
|
||||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||||
json = true;
|
json = true;
|
||||||
@@ -364,17 +371,20 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let records = store.list_trusted_runtimes(include_revoked)?;
|
let workspace_id = workspace_id.ok_or_else(|| {
|
||||||
|
CliError("trust-runtime list requires --workspace-id".to_string())
|
||||||
|
})?;
|
||||||
|
let records = store.list_workspace_runtime_bindings(&workspace_id, include_revoked)?;
|
||||||
if json {
|
if json {
|
||||||
println!("{}", serde_json::to_string_pretty(&records)?);
|
println!("{}", serde_json::to_string_pretty(&records)?);
|
||||||
} else {
|
} else {
|
||||||
for runtime in records {
|
for runtime in records {
|
||||||
println!(
|
println!(
|
||||||
"runtime_id={} workspace_id={} base_url={} public_key={} revoked_at={}",
|
"workspace_id={} runtime_id={} base_url={} public_key_fingerprint={} revoked_at={}",
|
||||||
|
runtime.workspace_id,
|
||||||
runtime.runtime_id,
|
runtime.runtime_id,
|
||||||
runtime.workspace_id.unwrap_or_default(),
|
|
||||||
runtime.base_url,
|
runtime.base_url,
|
||||||
runtime.public_key,
|
runtime.public_key_fingerprint.as_deref().unwrap_or(""),
|
||||||
runtime.revoked_at.unwrap_or_default()
|
runtime.revoked_at.unwrap_or_default()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -382,10 +392,14 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
"revoke" => {
|
"revoke" => {
|
||||||
|
let mut workspace_id = None;
|
||||||
let mut runtime_id = None;
|
let mut runtime_id = None;
|
||||||
while let Some(arg) = args.pop_front() {
|
while let Some(arg) = args.pop_front() {
|
||||||
let (flag, inline_value) = split_flag_value(arg)?;
|
let (flag, inline_value) = split_flag_value(arg)?;
|
||||||
match flag.as_str() {
|
match flag.as_str() {
|
||||||
|
"--workspace-id" => {
|
||||||
|
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
|
||||||
|
}
|
||||||
"--runtime-id" => {
|
"--runtime-id" => {
|
||||||
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
|
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
|
||||||
}
|
}
|
||||||
@@ -396,11 +410,14 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let workspace_id = workspace_id.ok_or_else(|| {
|
||||||
|
CliError("trust-runtime revoke requires --workspace-id".to_string())
|
||||||
|
})?;
|
||||||
let runtime_id = runtime_id.ok_or_else(|| {
|
let runtime_id = runtime_id.ok_or_else(|| {
|
||||||
CliError("trust-runtime revoke requires --runtime-id".to_string())
|
CliError("trust-runtime revoke requires --runtime-id".to_string())
|
||||||
})?;
|
})?;
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
if !store.revoke_trusted_runtime(&runtime_id, &now)? {
|
if !store.revoke_workspace_runtime_binding(&workspace_id, &runtime_id, &now)? {
|
||||||
return Err(Box::new(CliError(format!(
|
return Err(Box::new(CliError(format!(
|
||||||
"trusted runtime `{runtime_id}` is not registered or is already revoked"
|
"trusted runtime `{runtime_id}` is not registered or is already revoked"
|
||||||
))));
|
))));
|
||||||
@@ -414,24 +431,6 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_trusted_runtime_replace_allowed(
|
|
||||||
store: &SqliteWorkspaceStore,
|
|
||||||
runtime_id: &str,
|
|
||||||
replace: bool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
if store
|
|
||||||
.list_trusted_runtimes(true)?
|
|
||||||
.iter()
|
|
||||||
.any(|runtime| runtime.runtime_id == runtime_id)
|
|
||||||
&& !replace
|
|
||||||
{
|
|
||||||
return Err(Box::new(CliError(format!(
|
|
||||||
"trusted runtime `{runtime_id}` already exists; pass --replace to update it"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn split_flag_value(arg: String) -> Result<(String, Option<String>), CliError> {
|
fn split_flag_value(arg: String) -> Result<(String, Option<String>), CliError> {
|
||||||
if let Some((flag, value)) = arg.split_once('=') {
|
if let Some((flag, value)) = arg.split_once('=') {
|
||||||
if flag.is_empty() {
|
if flag.is_empty() {
|
||||||
@@ -543,16 +542,11 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
Some(path) => ServerHostConfigFile::load_from_path(path)?,
|
Some(path) => ServerHostConfigFile::load_from_path(path)?,
|
||||||
None => ServerHostConfigFile::load_default()?,
|
None => ServerHostConfigFile::load_default()?,
|
||||||
};
|
};
|
||||||
let runtime_config = BackendRuntimesConfigFile::load_default()?;
|
let mut resolved =
|
||||||
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
ResolvedWorkspaceBackendConfig::local_dev(&workspace_root, identity, &host_config)?;
|
||||||
&workspace_root,
|
|
||||||
identity,
|
|
||||||
&host_config,
|
|
||||||
&runtime_config,
|
|
||||||
)?;
|
|
||||||
resolved.database_path = database_path.clone();
|
resolved.database_path = database_path.clone();
|
||||||
resolved.server.database_path = database_path.clone();
|
resolved.server.database_path = database_path.clone();
|
||||||
append_trusted_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
|
append_workspace_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
|
||||||
if let Some(listen) = options.listen {
|
if let Some(listen) = options.listen {
|
||||||
resolved = resolved.with_listen(listen);
|
resolved = resolved.with_listen(listen);
|
||||||
}
|
}
|
||||||
@@ -572,22 +566,38 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_trusted_runtime_sources(
|
fn append_workspace_runtime_sources(
|
||||||
store: &SqliteWorkspaceStore,
|
store: &SqliteWorkspaceStore,
|
||||||
remote_runtime_sources: &mut Vec<RemoteRuntimeConfig>,
|
remote_runtime_sources: &mut Vec<RemoteRuntimeConfig>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let workspaces = store.list_workspaces()?;
|
||||||
|
let bindings = workspaces
|
||||||
|
.iter()
|
||||||
|
.map(|workspace| {
|
||||||
|
store
|
||||||
|
.list_workspace_runtime_bindings(&workspace.workspace_id, false)
|
||||||
|
.map(|bindings| {
|
||||||
|
bindings
|
||||||
|
.into_iter()
|
||||||
|
.filter(|binding| {
|
||||||
|
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
|
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
|
||||||
if !store.list_trusted_runtimes(false)?.is_empty() {
|
if !bindings.is_empty() {
|
||||||
return Err(Box::new(CliError(
|
return Err(Box::new(CliError(
|
||||||
"trusted runtimes are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
|
"Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
for runtime in store.list_trusted_runtimes(false)? {
|
for runtime in bindings {
|
||||||
let Some(workspace_id) = runtime.workspace_id.clone() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let auth = RemoteRuntimeAuthConfig {
|
let auth = RemoteRuntimeAuthConfig {
|
||||||
server_id: server_identity.identity.identity_id.clone(),
|
server_id: server_identity.identity.identity_id.clone(),
|
||||||
server_private_key: server_identity.identity.private_key.clone(),
|
server_private_key: server_identity.identity.private_key.clone(),
|
||||||
@@ -598,9 +608,12 @@ fn append_trusted_runtime_sources(
|
|||||||
runtime.base_url,
|
runtime.base_url,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.with_workspace_id(workspace_id)
|
.with_workspace_id(runtime.workspace_id.clone())
|
||||||
.with_auth(auth);
|
.with_auth(auth);
|
||||||
remote_runtime_sources.retain(|existing| existing.runtime_id != runtime.runtime_id);
|
remote_runtime_sources.retain(|existing| {
|
||||||
|
existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str())
|
||||||
|
|| existing.runtime_id != runtime.runtime_id
|
||||||
|
});
|
||||||
remote_runtime_sources.push(remote);
|
remote_runtime_sources.push(remote);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -731,7 +744,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
|||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list --workspace-id <WORKSPACE_ID> [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id <WORKSPACE_ID> --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +756,7 @@ fn print_skills_help() {
|
|||||||
|
|
||||||
fn print_serve_help() {
|
fn print_serve_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
|
"yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and Runtime bindings are loaded from the Server DB.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,30 +836,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn trusted_runtime_add_requires_replace_for_existing_record() {
|
fn runtime_binding_requires_explicit_replace_for_changed_authority() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let store = SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap();
|
let path = temp.path().join("server.db");
|
||||||
|
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||||
|
rusqlite::Connection::open(&path)
|
||||||
|
.unwrap()
|
||||||
|
.execute_batch(
|
||||||
|
"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
|
||||||
|
VALUES ('owner', 'user', 'owner', 'Owner', '1', '1');
|
||||||
|
INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at)
|
||||||
|
VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1');",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let public_key = RuntimeIdentityMaterial::generate("runtime-a")
|
let public_key = RuntimeIdentityMaterial::generate("runtime-a")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.public_key;
|
.public_key;
|
||||||
store
|
let binding = WorkspaceRuntimeBinding {
|
||||||
.upsert_trusted_runtime(&TrustedRuntimeRecord {
|
workspace_id: "workspace-a".to_string(),
|
||||||
runtime_id: "runtime-a".to_string(),
|
runtime_id: "runtime-a".to_string(),
|
||||||
workspace_id: None,
|
|
||||||
display_name: "Runtime A".to_string(),
|
display_name: "Runtime A".to_string(),
|
||||||
base_url: "http://127.0.0.1:18080".to_string(),
|
base_url: "http://127.0.0.1:18080".to_string(),
|
||||||
public_key,
|
public_key: Some(public_key),
|
||||||
|
public_key_fingerprint: None,
|
||||||
created_at: "2026-07-26T00:00:00Z".to_string(),
|
created_at: "2026-07-26T00:00:00Z".to_string(),
|
||||||
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
})
|
};
|
||||||
|
store
|
||||||
|
.upsert_workspace_runtime_binding(binding.clone(), false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
let error = ensure_trusted_runtime_replace_allowed(&store, "runtime-a", false).unwrap_err();
|
store
|
||||||
assert_eq!(
|
.upsert_workspace_runtime_binding(binding.clone(), false)
|
||||||
error.to_string(),
|
.unwrap(),
|
||||||
"trusted runtime `runtime-a` already exists; pass --replace to update it"
|
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged
|
||||||
|
));
|
||||||
|
let mut changed = binding;
|
||||||
|
changed.base_url = "http://127.0.0.1:18081".to_string();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.upsert_workspace_runtime_binding(changed.clone(), false)
|
||||||
|
.is_err()
|
||||||
);
|
);
|
||||||
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
|
store
|
||||||
|
.upsert_workspace_runtime_binding(changed, true)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ use crate::companion::{
|
|||||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||||
CompanionStatusResponse, CompanionTranscriptProjection,
|
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||||
};
|
};
|
||||||
use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime};
|
|
||||||
use crate::config_source::ConfigCommitRequest;
|
use crate::config_source::ConfigCommitRequest;
|
||||||
use crate::hosts::{
|
use crate::hosts::{
|
||||||
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID,
|
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID,
|
||||||
@@ -149,7 +148,7 @@ use crate::store::{
|
|||||||
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
||||||
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
|
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
|
||||||
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
||||||
WorkspaceResourceKind,
|
WorkspaceResourceKind, WorkspaceRuntimeBinding,
|
||||||
};
|
};
|
||||||
use crate::workdir_removal::{
|
use crate::workdir_removal::{
|
||||||
WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation,
|
WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation,
|
||||||
@@ -189,7 +188,6 @@ pub struct ServerConfig {
|
|||||||
pub repositories: Vec<ConfiguredRepository>,
|
pub repositories: Vec<ConfiguredRepository>,
|
||||||
pub runtime_event_sources: Vec<RuntimeObservationSourceConfig>,
|
pub runtime_event_sources: Vec<RuntimeObservationSourceConfig>,
|
||||||
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
|
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
|
||||||
pub runtime_config_path: Option<PathBuf>,
|
|
||||||
pub backend_base_url: Option<String>,
|
pub backend_base_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +215,6 @@ impl ServerConfig {
|
|||||||
repositories: Vec::new(),
|
repositories: Vec::new(),
|
||||||
runtime_event_sources: Vec::new(),
|
runtime_event_sources: Vec::new(),
|
||||||
remote_runtime_sources: Vec::new(),
|
remote_runtime_sources: Vec::new(),
|
||||||
runtime_config_path: BackendRuntimesConfigFile::default_path(),
|
|
||||||
backend_base_url: None,
|
backend_base_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1561,18 +1558,28 @@ impl WorkspaceApi {
|
|||||||
|
|
||||||
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
|
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
|
||||||
let resource_broker = BackendResourceBroker::default();
|
let resource_broker = BackendResourceBroker::default();
|
||||||
|
if store.get_workspace(&config.workspace_id).await?.is_none() {
|
||||||
|
return Err(crate::Error::Config(format!(
|
||||||
|
"Workspace {} is not registered in the Server DB",
|
||||||
|
config.workspace_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
let embedded_identity = (*EMBEDDED_RUNTIME_REQUEST_IDENTITY).clone();
|
let embedded_identity = (*EMBEDDED_RUNTIME_REQUEST_IDENTITY).clone();
|
||||||
store
|
store
|
||||||
.upsert_trusted_runtime_record(&crate::store::TrustedRuntimeRecord {
|
.upsert_workspace_runtime_binding_record(
|
||||||
|
crate::store::WorkspaceRuntimeBinding {
|
||||||
|
workspace_id: config.workspace_id.clone(),
|
||||||
runtime_id: EMBEDDED_RUNTIME_ID.to_owned(),
|
runtime_id: EMBEDDED_RUNTIME_ID.to_owned(),
|
||||||
workspace_id: None,
|
|
||||||
display_name: "Embedded Runtime".to_owned(),
|
display_name: "Embedded Runtime".to_owned(),
|
||||||
base_url: "in-process://embedded".to_owned(),
|
base_url: "in-process://embedded".to_owned(),
|
||||||
public_key: embedded_identity.public_key.clone(),
|
public_key: Some(embedded_identity.public_key.clone()),
|
||||||
|
public_key_fingerprint: None,
|
||||||
created_at: config.workspace_created_at.clone(),
|
created_at: config.workspace_created_at.clone(),
|
||||||
updated_at: config.workspace_created_at.clone(),
|
updated_at: config.workspace_created_at.clone(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
})
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let embedded_audience = format!("embedded:{}", config.workspace_id);
|
let embedded_audience = format!("embedded:{}", config.workspace_id);
|
||||||
let worker_remove_dispatcher = Arc::new(
|
let worker_remove_dispatcher = Arc::new(
|
||||||
@@ -8528,11 +8535,9 @@ async fn scoped_list_runtimes(
|
|||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
) -> ApiResult<Json<workspace_api::ListResponse<WorkspaceRuntimeResource>>> {
|
) -> ApiResult<Json<workspace_api::ListResponse<WorkspaceRuntimeResource>>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
Ok(Json(
|
||||||
Ok(Json(workspace_runtime_resources_response(
|
workspace_runtime_resources_response(&api, &api.config.workspace_id).await?,
|
||||||
&api,
|
))
|
||||||
&runtime_config,
|
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_workspace_protocol_ws(
|
async fn scoped_workspace_protocol_ws(
|
||||||
@@ -12395,7 +12400,6 @@ async fn create_remote_runtime(
|
|||||||
Json(request): Json<CreateRemoteRuntimeRequest>,
|
Json(request): Json<CreateRemoteRuntimeRequest>,
|
||||||
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
|
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
|
||||||
validate_runtime_connection_request(&request)?;
|
validate_runtime_connection_request(&request)?;
|
||||||
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
|
||||||
let id = request.runtime_id.trim().to_string();
|
let id = request.runtime_id.trim().to_string();
|
||||||
if id == EMBEDDED_WORKER_RUNTIME_ID {
|
if id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||||
return Err(settings_bad_request(
|
return Err(settings_bad_request(
|
||||||
@@ -12413,32 +12417,31 @@ async fn create_remote_runtime(
|
|||||||
"remote Runtime token_ref persistence is not supported",
|
"remote Runtime token_ref persistence is not supported",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if runtime_config
|
let now = Utc::now().to_rfc3339();
|
||||||
.runtimes
|
let binding = WorkspaceRuntimeBinding {
|
||||||
.remote
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
.iter()
|
runtime_id: id.clone(),
|
||||||
.any(|remote| remote.id == id)
|
|
||||||
{
|
|
||||||
return Err(settings_bad_request(
|
|
||||||
"remote_runtime_already_exists",
|
|
||||||
"a remote Runtime with that id already exists",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let remote_config = RemoteRuntimeConfigFile {
|
|
||||||
id: id.clone(),
|
|
||||||
endpoint: request.endpoint.trim().to_string(),
|
|
||||||
display_name: request
|
display_name: request
|
||||||
.display_name
|
.display_name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToOwned::to_owned),
|
.unwrap_or(&id)
|
||||||
token_ref: None,
|
.to_owned(),
|
||||||
|
base_url: request.endpoint.trim().to_string(),
|
||||||
|
public_key: None,
|
||||||
|
public_key_fingerprint: None,
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
revoked_at: None,
|
||||||
};
|
};
|
||||||
let active_config = remote_runtime_config_from_file(&remote_config).map_err(|diagnostic| {
|
api.store
|
||||||
|
.upsert_workspace_runtime_binding_record(binding.clone(), false)
|
||||||
|
.await?;
|
||||||
|
let active_config = remote_runtime_config_from_binding(&binding).map_err(|diagnostic| {
|
||||||
ApiError::with_diagnostics(
|
ApiError::with_diagnostics(
|
||||||
Error::RuntimeOperationFailed {
|
Error::RuntimeOperationFailed {
|
||||||
runtime_id: remote_config.id.clone(),
|
runtime_id: binding.runtime_id.clone(),
|
||||||
code: diagnostic.code.clone(),
|
code: diagnostic.code.clone(),
|
||||||
message: diagnostic.message.clone(),
|
message: diagnostic.message.clone(),
|
||||||
},
|
},
|
||||||
@@ -12455,10 +12458,9 @@ async fn create_remote_runtime(
|
|||||||
)
|
)
|
||||||
.map(|host| host.with_resource_broker(api.resource_broker.clone()))
|
.map(|host| host.with_resource_broker(api.resource_broker.clone()))
|
||||||
.map_err(|err| err.into_error())?;
|
.map_err(|err| err.into_error())?;
|
||||||
runtime_config.runtimes.remote.push(remote_config);
|
|
||||||
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
|
|
||||||
api.runtime.register_or_replace(active_runtime);
|
api.runtime.register_or_replace(active_runtime);
|
||||||
let resource = workspace_runtime_resource_by_id(&api, &runtime_config, &id)
|
let resource = workspace_runtime_resource_by_id(&api, &id)
|
||||||
|
.await?
|
||||||
.ok_or_else(|| Error::UnknownRuntime(id.clone()))?;
|
.ok_or_else(|| Error::UnknownRuntime(id.clone()))?;
|
||||||
Ok((StatusCode::CREATED, Json(resource)))
|
Ok((StatusCode::CREATED, Json(resource)))
|
||||||
}
|
}
|
||||||
@@ -12473,15 +12475,12 @@ async fn delete_remote_runtime(
|
|||||||
"the embedded Runtime is built in and cannot be deleted",
|
"the embedded Runtime is built in and cannot be deleted",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
let binding = api
|
||||||
let before = runtime_config.runtimes.remote.len();
|
.store
|
||||||
runtime_config
|
.get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
|
||||||
.runtimes
|
.await?
|
||||||
.remote
|
.filter(|binding| binding.revoked_at.is_none())
|
||||||
.retain(|remote| remote.id != runtime_id);
|
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
|
||||||
if before == runtime_config.runtimes.remote.len() {
|
|
||||||
return Err(Error::UnknownRuntime(runtime_id).into());
|
|
||||||
}
|
|
||||||
match api
|
match api
|
||||||
.runtime
|
.runtime
|
||||||
.unregister_if_idle(&runtime_id, api.config.max_records.min(200))
|
.unregister_if_idle(&runtime_id, api.config.max_records.min(200))
|
||||||
@@ -12510,7 +12509,14 @@ async fn delete_remote_runtime(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
|
let now = Utc::now().to_rfc3339();
|
||||||
|
if !api
|
||||||
|
.store
|
||||||
|
.revoke_workspace_runtime_binding_record(&binding.workspace_id, &binding.runtime_id, &now)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Err(Error::UnknownRuntime(runtime_id).into());
|
||||||
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12518,14 +12524,13 @@ async fn test_runtime_connection(
|
|||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(runtime_id): AxumPath<String>,
|
AxumPath(runtime_id): AxumPath<String>,
|
||||||
) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
|
) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
|
||||||
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
let binding = api
|
||||||
let remote = runtime_config
|
.store
|
||||||
.runtimes
|
.get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
|
||||||
.remote
|
.await?
|
||||||
.iter()
|
.filter(|binding| binding.revoked_at.is_none())
|
||||||
.find(|remote| remote.id == runtime_id)
|
|
||||||
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
|
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
|
||||||
Ok(Json(test_remote_runtime_config(&api, remote).await))
|
Ok(Json(test_remote_runtime_binding(&api, &binding).await))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_worker_launch_options(
|
async fn get_worker_launch_options(
|
||||||
@@ -14384,92 +14389,49 @@ fn workers_response(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_backend_runtimes_config_for_settings(
|
async fn workspace_runtime_resources_response(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
) -> ApiResult<BackendRuntimesConfigFile> {
|
workspace_id: &str,
|
||||||
api.config
|
) -> ApiResult<workspace_api::ListResponse<WorkspaceRuntimeResource>> {
|
||||||
.runtime_config_path
|
|
||||||
.as_ref()
|
|
||||||
.map(BackendRuntimesConfigFile::load_from_path)
|
|
||||||
.transpose()
|
|
||||||
.map_err(|error| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"failed to read Backend runtimes config for Runtime connections: {}",
|
|
||||||
sanitize_backend_error(&error.to_string())
|
|
||||||
))
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
.map(|config| config.unwrap_or_default())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_backend_runtimes_config_for_settings(
|
|
||||||
api: &WorkspaceApi,
|
|
||||||
runtime_config: &BackendRuntimesConfigFile,
|
|
||||||
) -> ApiResult<()> {
|
|
||||||
let path = api.config.runtime_config_path.as_ref().ok_or_else(|| {
|
|
||||||
Error::Config(
|
|
||||||
"Backend runtimes config path is unavailable; set YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
runtime_config.write_to_path(path).map_err(|error| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"failed to write Backend runtimes config for Runtime connections: {}",
|
|
||||||
sanitize_backend_error(&error.to_string())
|
|
||||||
))
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workspace_runtime_resources_response(
|
|
||||||
api: &WorkspaceApi,
|
|
||||||
runtime_config: &BackendRuntimesConfigFile,
|
|
||||||
) -> workspace_api::ListResponse<WorkspaceRuntimeResource> {
|
|
||||||
let limit = api.config.max_records.min(200);
|
let limit = api.config.max_records.min(200);
|
||||||
let runtimes = api.runtime.list_runtimes(limit);
|
let runtimes = api.runtime.list_runtimes(limit);
|
||||||
|
let bindings = api
|
||||||
|
.store
|
||||||
|
.list_workspace_runtime_bindings(workspace_id, false)
|
||||||
|
.await?;
|
||||||
let mut items = runtimes
|
let mut items = runtimes
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|runtime| {
|
.map(|runtime| {
|
||||||
let remote = runtime_config
|
let binding = bindings
|
||||||
.runtimes
|
|
||||||
.remote
|
|
||||||
.iter()
|
.iter()
|
||||||
.find(|remote| remote.id == runtime.runtime_id);
|
.find(|binding| binding.runtime_id == runtime.runtime_id);
|
||||||
let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID;
|
let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID;
|
||||||
WorkspaceRuntimeResource {
|
WorkspaceRuntimeResource {
|
||||||
runtime: runtime.into(),
|
runtime: runtime.into(),
|
||||||
management: RuntimeManagementSummary {
|
management: RuntimeManagementSummary {
|
||||||
built_in,
|
built_in,
|
||||||
config_managed: remote.is_some(),
|
config_managed: binding.is_some(),
|
||||||
removable: remote.is_some() && !built_in,
|
removable: binding.is_some() && !built_in,
|
||||||
endpoint_configured: remote
|
endpoint_configured: binding
|
||||||
.is_some_and(|remote| !remote.endpoint.trim().is_empty()),
|
.is_some_and(|binding| !binding.base_url.trim().is_empty()),
|
||||||
token_ref_configured: remote.is_some_and(|remote| {
|
token_ref_configured: false,
|
||||||
remote
|
|
||||||
.token_ref
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|value| !value.trim().is_empty())
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
for remote in &runtime_config.runtimes.remote {
|
for binding in &bindings {
|
||||||
if items
|
if items
|
||||||
.iter()
|
.iter()
|
||||||
.any(|resource| resource.runtime.runtime_id == remote.id)
|
.any(|resource| resource.runtime.runtime_id == binding.runtime_id)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
items.push(WorkspaceRuntimeResource {
|
items.push(WorkspaceRuntimeResource {
|
||||||
runtime: workspace_api::RuntimeSummary {
|
runtime: workspace_api::RuntimeSummary {
|
||||||
runtime_id: remote.id.clone(),
|
runtime_id: binding.runtime_id.clone(),
|
||||||
label: remote
|
label: binding.display_name.clone(),
|
||||||
.display_name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| remote.id.clone()),
|
|
||||||
kind: "remote_http".to_string(),
|
kind: "remote_http".to_string(),
|
||||||
status: "unavailable".to_string(),
|
status: "unavailable".to_string(),
|
||||||
source: workspace_api::RuntimeSourceSummary {
|
source: workspace_api::RuntimeSourceSummary {
|
||||||
@@ -14477,7 +14439,7 @@ fn workspace_runtime_resources_response(
|
|||||||
status: workspace_api::RuntimeSourceStatus::Reserved,
|
status: workspace_api::RuntimeSourceStatus::Reserved,
|
||||||
identity_authority:
|
identity_authority:
|
||||||
workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration,
|
workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration,
|
||||||
note: "The configured Runtime is not present in the active Runtime registry."
|
note: "The registered Runtime is not present in the active Runtime registry."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
},
|
},
|
||||||
host_ids: Vec::new(),
|
host_ids: Vec::new(),
|
||||||
@@ -14486,9 +14448,9 @@ fn workspace_runtime_resources_response(
|
|||||||
arch: String::new(),
|
arch: String::new(),
|
||||||
diagnostics: vec![
|
diagnostics: vec![
|
||||||
settings_diagnostic(
|
settings_diagnostic(
|
||||||
"configured_runtime_unavailable",
|
"registered_runtime_unavailable",
|
||||||
DiagnosticSeverity::Warning,
|
DiagnosticSeverity::Warning,
|
||||||
"The configured Runtime is not present in the active Runtime registry.",
|
"The registered Runtime is not present in the active Runtime registry.",
|
||||||
)
|
)
|
||||||
.into(),
|
.into(),
|
||||||
],
|
],
|
||||||
@@ -14497,33 +14459,32 @@ fn workspace_runtime_resources_response(
|
|||||||
built_in: false,
|
built_in: false,
|
||||||
config_managed: true,
|
config_managed: true,
|
||||||
removable: true,
|
removable: true,
|
||||||
endpoint_configured: !remote.endpoint.trim().is_empty(),
|
endpoint_configured: !binding.base_url.trim().is_empty(),
|
||||||
token_ref_configured: remote
|
token_ref_configured: false,
|
||||||
.token_ref
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|value| !value.trim().is_empty()),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
workspace_api::ListResponse {
|
Ok(workspace_api::ListResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: workspace_id.to_string(),
|
||||||
limit,
|
limit,
|
||||||
items,
|
items,
|
||||||
source: "workspace-runtime-resources".to_string(),
|
source: "workspace-runtime-bindings".to_string(),
|
||||||
diagnostics: runtimes.diagnostics.into_iter().map(Into::into).collect(),
|
diagnostics: runtimes.diagnostics.into_iter().map(Into::into).collect(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_runtime_resource_by_id(
|
async fn workspace_runtime_resource_by_id(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
runtime_config: &BackendRuntimesConfigFile,
|
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
) -> Option<WorkspaceRuntimeResource> {
|
) -> ApiResult<Option<WorkspaceRuntimeResource>> {
|
||||||
workspace_runtime_resources_response(api, runtime_config)
|
Ok(
|
||||||
|
workspace_runtime_resources_response(api, &api.config.workspace_id)
|
||||||
|
.await?
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|resource| resource.runtime.runtime_id == runtime_id)
|
.find(|resource| resource.runtime.runtime_id == runtime_id),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> {
|
fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> {
|
||||||
@@ -14569,46 +14530,24 @@ fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remote_runtime_config_from_file(
|
fn remote_runtime_config_from_binding(
|
||||||
remote: &RemoteRuntimeConfigFile,
|
binding: &WorkspaceRuntimeBinding,
|
||||||
) -> std::result::Result<RemoteRuntimeConfig, RuntimeDiagnostic> {
|
) -> std::result::Result<RemoteRuntimeConfig, RuntimeDiagnostic> {
|
||||||
resolve_remote_runtime(remote).map_err(|err| {
|
let remote = RemoteRuntimeConfig::new(
|
||||||
settings_diagnostic(
|
binding.runtime_id.clone(),
|
||||||
"remote_runtime_apply_failed",
|
binding.display_name.clone(),
|
||||||
DiagnosticSeverity::Error,
|
binding.base_url.clone(),
|
||||||
err.to_string(),
|
None,
|
||||||
)
|
)
|
||||||
})
|
.with_workspace_id(binding.workspace_id.clone());
|
||||||
|
Ok(remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn test_remote_runtime_config(
|
async fn test_remote_runtime_binding(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
remote: &RemoteRuntimeConfigFile,
|
remote: &WorkspaceRuntimeBinding,
|
||||||
) -> RuntimeConnectionTestResponse {
|
) -> RuntimeConnectionTestResponse {
|
||||||
let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
||||||
if remote
|
|
||||||
.token_ref
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|value| !value.trim().is_empty())
|
|
||||||
{
|
|
||||||
return RuntimeConnectionTestResponse {
|
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
|
||||||
runtime_id: remote.id.clone(),
|
|
||||||
checked_at,
|
|
||||||
state: "rejected".to_string(),
|
|
||||||
protocol_version: None,
|
|
||||||
compatibility_basis: "not_checked_token_ref_unsupported".to_string(),
|
|
||||||
capabilities: Vec::new(),
|
|
||||||
health_result: "not_checked".to_string(),
|
|
||||||
diagnostics: vec![settings_diagnostic(
|
|
||||||
"remote_runtime_token_ref_unsupported",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
"Remote Runtime test cannot use token_ref in v0; no token or secret value was exposed to the Browser.",
|
|
||||||
)
|
|
||||||
.into()],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(5))
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
.build()
|
.build()
|
||||||
@@ -14866,7 +14805,7 @@ async fn test_remote_runtime_config(
|
|||||||
|
|
||||||
RuntimeConnectionTestResponse {
|
RuntimeConnectionTestResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
runtime_id: remote.id.clone(),
|
runtime_id: remote.runtime_id.clone(),
|
||||||
checked_at,
|
checked_at,
|
||||||
state: observation.state().to_string(),
|
state: observation.state().to_string(),
|
||||||
protocol_version,
|
protocol_version,
|
||||||
@@ -14889,14 +14828,14 @@ async fn test_remote_runtime_config(
|
|||||||
|
|
||||||
fn remote_runtime_test_failed(
|
fn remote_runtime_test_failed(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
remote: &RemoteRuntimeConfigFile,
|
remote: &WorkspaceRuntimeBinding,
|
||||||
checked_at: String,
|
checked_at: String,
|
||||||
code: impl Into<String>,
|
code: impl Into<String>,
|
||||||
message: impl Into<String>,
|
message: impl Into<String>,
|
||||||
) -> RuntimeConnectionTestResponse {
|
) -> RuntimeConnectionTestResponse {
|
||||||
RuntimeConnectionTestResponse {
|
RuntimeConnectionTestResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
runtime_id: remote.id.clone(),
|
runtime_id: remote.runtime_id.clone(),
|
||||||
checked_at,
|
checked_at,
|
||||||
state: "failed".to_string(),
|
state: "failed".to_string(),
|
||||||
protocol_version: None,
|
protocol_version: None,
|
||||||
@@ -14953,10 +14892,10 @@ impl RuntimeCompatibilityObservation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn remote_probe_url(
|
fn remote_probe_url(
|
||||||
remote: &RemoteRuntimeConfigFile,
|
remote: &WorkspaceRuntimeBinding,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> std::result::Result<String, RuntimeDiagnostic> {
|
) -> std::result::Result<String, RuntimeDiagnostic> {
|
||||||
let endpoint = remote.endpoint.trim();
|
let endpoint = remote.base_url.trim();
|
||||||
if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) {
|
if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) {
|
||||||
return Err(settings_diagnostic(
|
return Err(settings_diagnostic(
|
||||||
"remote_runtime_endpoint_invalid",
|
"remote_runtime_endpoint_invalid",
|
||||||
@@ -16400,6 +16339,7 @@ impl IntoResponse for ApiError {
|
|||||||
Error::TicketAssignmentConflict(_)
|
Error::TicketAssignmentConflict(_)
|
||||||
| Error::WorkdirAttachmentConflict(_)
|
| Error::WorkdirAttachmentConflict(_)
|
||||||
| Error::WorkspaceConfigConflict(_)
|
| Error::WorkspaceConfigConflict(_)
|
||||||
|
| Error::RuntimeBindingConflict(_)
|
||||||
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
||||||
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||||
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||||
@@ -16539,7 +16479,6 @@ impl IntoResponse for ApiError {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::WorkspaceBackendRuntimesConfig;
|
|
||||||
use axum::body::{Body, to_bytes};
|
use axum::body::{Body, to_bytes};
|
||||||
use axum::http::Request;
|
use axum::http::Request;
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
@@ -16565,7 +16504,7 @@ mod tests {
|
|||||||
use crate::store::{
|
use crate::store::{
|
||||||
AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord,
|
AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord,
|
||||||
MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, ObjectiveTicketLinkRecord,
|
MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, ObjectiveTicketLinkRecord,
|
||||||
SqliteWorkspaceStore, TrustedRuntimeRecord, UserRecord, WorkspaceRecord,
|
SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn handler_source<'a>(source: &'a str, name: &str) -> &'a str {
|
fn handler_source<'a>(source: &'a str, name: &str) -> &'a str {
|
||||||
@@ -16926,16 +16865,20 @@ mod tests {
|
|||||||
});
|
});
|
||||||
SqliteWorkspaceStore::open(&api.config.database_path)
|
SqliteWorkspaceStore::open(&api.config.database_path)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.upsert_trusted_runtime(&TrustedRuntimeRecord {
|
.upsert_workspace_runtime_binding(
|
||||||
|
WorkspaceRuntimeBinding {
|
||||||
|
workspace_id: api.workspace_id().to_owned(),
|
||||||
runtime_id: runtime_id.to_owned(),
|
runtime_id: runtime_id.to_owned(),
|
||||||
workspace_id: Some(api.workspace_id().to_owned()),
|
|
||||||
display_name: runtime_id.to_owned(),
|
display_name: runtime_id.to_owned(),
|
||||||
base_url: "https://runtime.test".to_owned(),
|
base_url: "https://runtime.test".to_owned(),
|
||||||
public_key: identity.public_key.clone(),
|
public_key: Some(identity.public_key.clone()),
|
||||||
|
public_key_fingerprint: None,
|
||||||
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
})
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18901,13 +18844,59 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_runtime_sources_are_scoped_by_workspace_and_runtime_id() {
|
||||||
|
let mut base = test_server_config(tempfile::tempdir().unwrap().path());
|
||||||
|
base.remote_runtime_sources = vec![
|
||||||
|
RemoteRuntimeConfig::new("shared", "A", "https://a.runtime.test", None)
|
||||||
|
.with_workspace_id("workspace-a"),
|
||||||
|
RemoteRuntimeConfig::new("shared", "B", "https://b.runtime.test", None)
|
||||||
|
.with_workspace_id("workspace-b"),
|
||||||
|
];
|
||||||
|
let workspace = WorkspaceRecord {
|
||||||
|
workspace_id: "workspace-b".to_string(),
|
||||||
|
display_name: "Workspace B".to_string(),
|
||||||
|
state: "active".to_string(),
|
||||||
|
owner_account_id: "owner-account".to_string(),
|
||||||
|
created_at: "1".to_string(),
|
||||||
|
updated_at: "1".to_string(),
|
||||||
|
};
|
||||||
|
let source = workspace_api::RepositorySource {
|
||||||
|
kind: workspace_api::RepositorySourceKind::Https,
|
||||||
|
uri: "https://example.test/org/repository.git".to_string(),
|
||||||
|
};
|
||||||
|
let repositories = vec![RepositoryRecord {
|
||||||
|
workspace_id: "workspace-b".to_string(),
|
||||||
|
repository_id: "main".to_string(),
|
||||||
|
repository_key: "main".to_string(),
|
||||||
|
kind: "git".to_string(),
|
||||||
|
provider: Some("git".to_string()),
|
||||||
|
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||||
|
source,
|
||||||
|
default_ref: Some("main".to_string()),
|
||||||
|
source_revision: 1,
|
||||||
|
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
||||||
|
observed_at: None,
|
||||||
|
created_at: "1".to_string(),
|
||||||
|
updated_at: "1".to_string(),
|
||||||
|
}];
|
||||||
|
let scoped = base
|
||||||
|
.for_catalog_workspace(&workspace, repositories)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(scoped.remote_runtime_sources.len(), 1);
|
||||||
|
assert_eq!(scoped.remote_runtime_sources[0].runtime_id, "shared");
|
||||||
|
assert_eq!(
|
||||||
|
scoped.remote_runtime_sources[0].base_url,
|
||||||
|
"https://b.runtime.test"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn test_server_config(workspace_root: impl Into<PathBuf>) -> ServerConfig {
|
fn test_server_config(workspace_root: impl Into<PathBuf>) -> ServerConfig {
|
||||||
let workspace_root = workspace_root.into();
|
let workspace_root = workspace_root.into();
|
||||||
let store_root = workspace_root.join(".test-embedded-runtime-store");
|
let store_root = workspace_root.join(".test-embedded-runtime-store");
|
||||||
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity())
|
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity())
|
||||||
.with_embedded_runtime_store_root(store_root);
|
.with_embedded_runtime_store_root(store_root);
|
||||||
config.database_path = workspace_root.join(".test-yoi-server.db");
|
config.database_path = workspace_root.join(".test-yoi-server.db");
|
||||||
config.runtime_config_path = Some(workspace_root.join(".test-config/runtimes.toml"));
|
|
||||||
let source = workspace_api::RepositorySource {
|
let source = workspace_api::RepositorySource {
|
||||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||||
uri: workspace_root.display().to_string(),
|
uri: workspace_root.display().to_string(),
|
||||||
@@ -22906,17 +22895,20 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let trust = crate::store::TrustedRuntimeRecord {
|
let trust = crate::store::WorkspaceRuntimeBinding {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
runtime_id: "runtime-remote".to_string(),
|
runtime_id: "runtime-remote".to_string(),
|
||||||
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
|
|
||||||
display_name: "Remote Runtime".to_string(),
|
display_name: "Remote Runtime".to_string(),
|
||||||
base_url: "https://runtime.invalid".to_string(),
|
base_url: "https://runtime.invalid".to_string(),
|
||||||
public_key: identity.public_key.clone(),
|
public_key: Some(identity.public_key.clone()),
|
||||||
|
public_key_fingerprint: None,
|
||||||
created_at: "2026-08-11T00:00:00Z".to_string(),
|
created_at: "2026-08-11T00:00:00Z".to_string(),
|
||||||
updated_at: "2026-08-11T00:00:00Z".to_string(),
|
updated_at: "2026-08-11T00:00:00Z".to_string(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
};
|
};
|
||||||
store.upsert_trusted_runtime(&trust).unwrap();
|
store
|
||||||
|
.upsert_workspace_runtime_binding(trust.clone(), false)
|
||||||
|
.unwrap();
|
||||||
let api = WorkspaceApi::new_with_execution_backend(
|
let api = WorkspaceApi::new_with_execution_backend(
|
||||||
config,
|
config,
|
||||||
Arc::new(store),
|
Arc::new(store),
|
||||||
@@ -23179,7 +23171,9 @@ mod tests {
|
|||||||
let mut revoked = trust;
|
let mut revoked = trust;
|
||||||
revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string());
|
revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string());
|
||||||
let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap();
|
let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap();
|
||||||
authority.upsert_trusted_runtime(&revoked).unwrap();
|
authority
|
||||||
|
.upsert_workspace_runtime_binding(revoked, true)
|
||||||
|
.unwrap();
|
||||||
let revoked_token = signer
|
let revoked_token = signer
|
||||||
.issue_worker_remove(
|
.issue_worker_remove(
|
||||||
"server-main",
|
"server-main",
|
||||||
@@ -23190,16 +23184,20 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
let revoked_result = crate::worker_source::verify_worker_remove_source(
|
||||||
crate::worker_source::verify_worker_remove_source(
|
|
||||||
&api,
|
&api,
|
||||||
crate::worker_source::PresentedWorkerMutationSourceProof::Remote(&revoked_token),
|
crate::worker_source::PresentedWorkerMutationSourceProof::Remote(&revoked_token),
|
||||||
"runtime-target",
|
"runtime-target",
|
||||||
"target-worker",
|
"target-worker",
|
||||||
)
|
)
|
||||||
.await,
|
.await;
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
revoked_result,
|
||||||
Err(crate::worker_source::WorkerMutationSourceProofError::RevokedRuntimeTrust)
|
Err(crate::worker_source::WorkerMutationSourceProofError::RevokedRuntimeTrust)
|
||||||
));
|
),
|
||||||
|
"unexpected revoked trust result: {revoked_result:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seed_worker_source_member(api: &WorkspaceApi, runtime_id: &str, worker_id: &str) {
|
fn seed_worker_source_member(api: &WorkspaceApi, runtime_id: &str, worker_id: &str) {
|
||||||
@@ -24431,7 +24429,7 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
Err(crate::worker_source::WorkerMutationSourceProofError::WrongWorkspace)
|
Err(crate::worker_source::WorkerMutationSourceProofError::RevokedRuntimeTrust)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25413,7 +25411,9 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_rest_resource_create_list_and_delete_apply_live_registry() {
|
async fn runtime_rest_resource_create_list_and_delete_apply_live_registry() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let app = test_app(dir.path()).await;
|
let api = test_api(dir.path()).await;
|
||||||
|
let store = api.store.clone();
|
||||||
|
let app = build_inner_router(api);
|
||||||
let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes");
|
let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes");
|
||||||
|
|
||||||
let initial = get_json(app.clone(), &runtimes_uri).await;
|
let initial = get_json(app.clone(), &runtimes_uri).await;
|
||||||
@@ -25460,16 +25460,14 @@ mod tests {
|
|||||||
let projected = serde_json::to_string(&added).unwrap();
|
let projected = serde_json::to_string(&added).unwrap();
|
||||||
assert!(!projected.contains("runtime.example.invalid"));
|
assert!(!projected.contains("runtime.example.invalid"));
|
||||||
|
|
||||||
let persisted = BackendRuntimesConfigFile::load_from_path(
|
let persisted = store
|
||||||
dir.path().join(".test-config/runtimes.toml"),
|
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "team-runtime")
|
||||||
)
|
.await
|
||||||
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(persisted.runtimes.remote.len(), 1);
|
assert_eq!(persisted.runtime_id, "team-runtime");
|
||||||
assert_eq!(persisted.runtimes.remote[0].id, "team-runtime");
|
assert_eq!(persisted.base_url, "https://runtime.example.invalid");
|
||||||
assert_eq!(
|
assert!(persisted.revoked_at.is_none());
|
||||||
persisted.runtimes.remote[0].endpoint,
|
|
||||||
"https://runtime.example.invalid"
|
|
||||||
);
|
|
||||||
|
|
||||||
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
|
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
|
||||||
let runtimes = launch_options["runtimes"].as_array().unwrap();
|
let runtimes = launch_options["runtimes"].as_array().unwrap();
|
||||||
@@ -25501,11 +25499,12 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|runtime| runtime["runtime_id"] == "team-runtime")
|
.any(|runtime| runtime["runtime_id"] == "team-runtime")
|
||||||
);
|
);
|
||||||
let persisted = BackendRuntimesConfigFile::load_from_path(
|
let persisted = store
|
||||||
dir.path().join(".test-config/runtimes.toml"),
|
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "team-runtime")
|
||||||
)
|
.await
|
||||||
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(persisted.runtimes.remote.is_empty());
|
assert!(persisted.revoked_at.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
@@ -25568,11 +25567,6 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|diagnostic| { diagnostic["code"] == "remote_runtime_delete_blocked" })
|
.any(|diagnostic| { diagnostic["code"] == "remote_runtime_delete_blocked" })
|
||||||
);
|
);
|
||||||
let persisted = BackendRuntimesConfigFile::load_from_path(
|
|
||||||
dir.path().join(".test-config/runtimes.toml"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(persisted.runtimes.remote.len(), 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
@@ -25592,19 +25586,25 @@ mod tests {
|
|||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let endpoint = format!("http://{runtime_addr}");
|
let endpoint = format!("http://{runtime_addr}");
|
||||||
BackendRuntimesConfigFile {
|
let api = test_api(dir.path()).await;
|
||||||
runtimes: WorkspaceBackendRuntimesConfig {
|
api.store
|
||||||
remote: vec![RemoteRuntimeConfigFile {
|
.upsert_workspace_runtime_binding_record(
|
||||||
id: "probe-runtime".to_string(),
|
WorkspaceRuntimeBinding {
|
||||||
endpoint: endpoint.clone(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
display_name: Some("Probe Runtime".to_string()),
|
runtime_id: "probe-runtime".to_string(),
|
||||||
token_ref: None,
|
display_name: "Probe Runtime".to_string(),
|
||||||
}],
|
base_url: endpoint.clone(),
|
||||||
|
public_key: None,
|
||||||
|
public_key_fingerprint: None,
|
||||||
|
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
revoked_at: None,
|
||||||
},
|
},
|
||||||
}
|
false,
|
||||||
.write_to_path(dir.path().join(".test-config/runtimes.toml"))
|
)
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let app = test_app(dir.path()).await;
|
let app = build_inner_router(api);
|
||||||
|
|
||||||
let response = post_json(
|
let response = post_json(
|
||||||
app,
|
app,
|
||||||
@@ -25656,19 +25656,25 @@ mod tests {
|
|||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let endpoint = format!("http://{runtime_addr}");
|
let endpoint = format!("http://{runtime_addr}");
|
||||||
BackendRuntimesConfigFile {
|
let api = test_api(dir.path()).await;
|
||||||
runtimes: WorkspaceBackendRuntimesConfig {
|
api.store
|
||||||
remote: vec![RemoteRuntimeConfigFile {
|
.upsert_workspace_runtime_binding_record(
|
||||||
id: "control-only-runtime".to_string(),
|
WorkspaceRuntimeBinding {
|
||||||
display_name: Some("Control-only Runtime".to_string()),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
endpoint,
|
runtime_id: "control-only-runtime".to_string(),
|
||||||
token_ref: None,
|
display_name: "Control-only Runtime".to_string(),
|
||||||
}],
|
base_url: endpoint,
|
||||||
|
public_key: None,
|
||||||
|
public_key_fingerprint: None,
|
||||||
|
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
revoked_at: None,
|
||||||
},
|
},
|
||||||
}
|
false,
|
||||||
.write_to_path(dir.path().join(".test-config/runtimes.toml"))
|
)
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let app = test_app(dir.path()).await;
|
let app = build_inner_router(api);
|
||||||
|
|
||||||
let response = post_json(
|
let response = post_json(
|
||||||
app,
|
app,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -57,16 +57,15 @@ pub async fn verify_runtime_request_source_proof_with_store(
|
|||||||
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
|
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
|
||||||
let audience = remote_audience(config, &unverified.iss, workspace_id)?;
|
let audience = remote_audience(config, &unverified.iss, workspace_id)?;
|
||||||
let trusted = store
|
let trusted = store
|
||||||
.get_trusted_runtime(&unverified.iss)
|
.get_workspace_runtime_binding(workspace_id, &unverified.iss)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
|
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
|
||||||
.filter(|record| record.revoked_at.is_none())
|
.filter(|record| record.revoked_at.is_none() && record.public_key.is_some())
|
||||||
|
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
|
||||||
|
let public_key = trusted
|
||||||
|
.public_key
|
||||||
|
.as_deref()
|
||||||
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
|
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
|
||||||
let trusted_for_workspace = trusted.workspace_id.as_deref() == Some(workspace_id)
|
|
||||||
|| (unverified.iss == crate::hosts::EMBEDDED_RUNTIME_ID && trusted.workspace_id.is_none());
|
|
||||||
if !trusted_for_workspace {
|
|
||||||
return Err(WorkerMutationSourceProofError::WrongWorkspace);
|
|
||||||
}
|
|
||||||
let expected = RuntimeRequestSourceExpectation {
|
let expected = RuntimeRequestSourceExpectation {
|
||||||
identity_id: &unverified.iss,
|
identity_id: &unverified.iss,
|
||||||
audience: audience.as_ref(),
|
audience: audience.as_ref(),
|
||||||
@@ -78,8 +77,8 @@ pub async fn verify_runtime_request_source_proof_with_store(
|
|||||||
body_digest,
|
body_digest,
|
||||||
now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||||
};
|
};
|
||||||
let claims = verify_runtime_request_source(proof, &trusted.public_key, &expected)
|
let claims =
|
||||||
.map_err(map_auth_error)?;
|
verify_runtime_request_source(proof, public_key, &expected).map_err(map_auth_error)?;
|
||||||
let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX);
|
let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX);
|
||||||
let expires_at = u64::try_from(claims.exp).unwrap_or(0);
|
let expires_at = u64::try_from(claims.exp).unwrap_or(0);
|
||||||
let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0)
|
let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0)
|
||||||
@@ -87,6 +86,7 @@ pub async fn verify_runtime_request_source_proof_with_store(
|
|||||||
.to_rfc3339();
|
.to_rfc3339();
|
||||||
if !store
|
if !store
|
||||||
.consume_worker_mutation_source_jti(
|
.consume_worker_mutation_source_jti(
|
||||||
|
workspace_id,
|
||||||
&claims.iss,
|
&claims.iss,
|
||||||
&claims.jti,
|
&claims.jti,
|
||||||
expires_at,
|
expires_at,
|
||||||
@@ -206,14 +206,15 @@ async fn verify_worker_remove_source_with(
|
|||||||
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
|
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
|
||||||
let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?;
|
let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?;
|
||||||
let trusted = store
|
let trusted = store
|
||||||
.get_trusted_runtime(&unverified.iss)
|
.get_workspace_runtime_binding(&config.workspace_id, &unverified.iss)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
|
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
|
||||||
.filter(|record| record.revoked_at.is_none())
|
.filter(|record| record.revoked_at.is_none() && record.public_key.is_some())
|
||||||
|
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
|
||||||
|
let public_key = trusted
|
||||||
|
.public_key
|
||||||
|
.as_deref()
|
||||||
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
|
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
|
||||||
if trusted.workspace_id.as_deref() != Some(config.workspace_id.as_str()) {
|
|
||||||
return Err(WorkerMutationSourceProofError::WrongWorkspace);
|
|
||||||
}
|
|
||||||
let expected = WorkerMutationSourceExpectation {
|
let expected = WorkerMutationSourceExpectation {
|
||||||
runtime_id: &unverified.iss,
|
runtime_id: &unverified.iss,
|
||||||
audience: audience.as_ref(),
|
audience: audience.as_ref(),
|
||||||
@@ -225,7 +226,7 @@ async fn verify_worker_remove_source_with(
|
|||||||
target_worker_id,
|
target_worker_id,
|
||||||
permission: required_permission,
|
permission: required_permission,
|
||||||
};
|
};
|
||||||
verify_worker_mutation_source_proof(&trusted.public_key, token, &expected, now)
|
verify_worker_mutation_source_proof(public_key, token, &expected, now)
|
||||||
.map_err(map_auth_error)?
|
.map_err(map_auth_error)?
|
||||||
}
|
}
|
||||||
PresentedWorkerMutationSourceProof::InProcess(proof) => {
|
PresentedWorkerMutationSourceProof::InProcess(proof) => {
|
||||||
@@ -263,7 +264,14 @@ async fn verify_worker_remove_source_with(
|
|||||||
|
|
||||||
let consumed_at = chrono::Utc::now().to_rfc3339();
|
let consumed_at = chrono::Utc::now().to_rfc3339();
|
||||||
let consumed = store
|
let consumed = store
|
||||||
.consume_worker_mutation_source_jti(&claims.iss, &claims.jti, claims.exp, now, &consumed_at)
|
.consume_worker_mutation_source_jti(
|
||||||
|
&config.workspace_id,
|
||||||
|
&claims.iss,
|
||||||
|
&claims.jti,
|
||||||
|
claims.exp,
|
||||||
|
now,
|
||||||
|
&consumed_at,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
||||||
if !consumed {
|
if !consumed {
|
||||||
|
|||||||
@@ -110,18 +110,19 @@ On the Workspace Server host, register the Runtime public key copied from `yoi-r
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
yoi-server trust-runtime add \
|
yoi-server trust-runtime add \
|
||||||
|
--workspace-id '<WORKSPACE_ID>' \
|
||||||
--runtime-id runtime-main \
|
--runtime-id runtime-main \
|
||||||
--base-url http://127.0.0.1:38800 \
|
--base-url http://127.0.0.1:38800 \
|
||||||
--public-key '<RUNTIME_PUBLIC_KEY>' \
|
--public-key '<RUNTIME_PUBLIC_KEY>' \
|
||||||
--display-name 'Runtime main'
|
--display-name 'Runtime main'
|
||||||
```
|
```
|
||||||
|
|
||||||
This writes a trusted Runtime record to the Server DB. During `yoi-server serve`, active trusted Runtime records are loaded as remote Runtime sources and receive signed capability tokens. You do not need to duplicate the same Runtime in `runtimes.toml` for this trust-backed path.
|
This writes a Workspace-scoped Runtime binding and trust fingerprint to the Server DB. During `yoi-server serve`, active bindings are loaded as remote Runtime sources and receive signed capability tokens. Repository-external Runtime files are not registration or trust authority.
|
||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yoi-server trust-runtime list --json
|
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. Start Runtime and Workspace Server
|
## 5. Start Runtime and Workspace Server
|
||||||
@@ -160,7 +161,7 @@ An empty Server DB is valid. Open the Web UI, create or authenticate the Account
|
|||||||
Check both trust stores:
|
Check both trust stores:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yoi-server trust-runtime list --json
|
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
|
||||||
yoi-runtime trust-server list --json
|
yoi-runtime trust-server list --json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -203,6 +204,7 @@ After Runtime identity rotation, Server must be updated with the new Runtime pub
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
yoi-server trust-runtime add \
|
yoi-server trust-runtime add \
|
||||||
|
--workspace-id '<WORKSPACE_ID>' \
|
||||||
--runtime-id runtime-main \
|
--runtime-id runtime-main \
|
||||||
--base-url http://127.0.0.1:38800 \
|
--base-url http://127.0.0.1:38800 \
|
||||||
--public-key '<NEW_RUNTIME_PUBLIC_KEY>' \
|
--public-key '<NEW_RUNTIME_PUBLIC_KEY>' \
|
||||||
@@ -214,7 +216,9 @@ yoi-server trust-runtime add \
|
|||||||
Revoke a trusted Runtime on Server:
|
Revoke a trusted Runtime on Server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yoi-server trust-runtime revoke --runtime-id runtime-main
|
yoi-server trust-runtime revoke \
|
||||||
|
--workspace-id '<WORKSPACE_ID>' \
|
||||||
|
--runtime-id runtime-main
|
||||||
```
|
```
|
||||||
|
|
||||||
Remove a trusted Server from Runtime:
|
Remove a trusted Server from Runtime:
|
||||||
@@ -252,7 +256,7 @@ Confirm the `--runtime-id` registered on Server exactly matches the Runtime iden
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
yoi-runtime identity show --json
|
yoi-runtime identity show --json
|
||||||
yoi-server trust-runtime list --json
|
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
|
||||||
```
|
```
|
||||||
|
|
||||||
`RUNTIME_ID` is the token audience; mismatches are rejected by Runtime.
|
`RUNTIME_ID` is the token audience; mismatches are rejected by Runtime.
|
||||||
|
|||||||
Reference in New Issue
Block a user