feat: add workspace repository registration API

This commit is contained in:
2026-08-31 18:12:08 +09:00
parent 456a06f194
commit 7d09b20445
4 changed files with 319 additions and 11 deletions
+2
View File
@@ -117,6 +117,8 @@ pub enum Error {
WorkspacePermissionDenied(String),
#[error("Workspace config update conflict: {0}")]
WorkspaceConfigConflict(String),
#[error("Repository conflict: {0}")]
RepositoryConflict(String),
#[error("Registry inconsistency: {0}")]
RegistryInconsistency(String),
#[error("Worker source identity is invalid: {0}")]
+241 -11
View File
@@ -61,6 +61,7 @@ use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use workspace_api::{
CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest,
CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse,
DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest,
ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection,
@@ -121,14 +122,15 @@ use crate::repository_access::{
RepositoryAccessConfigSchemaProvider, RepositorySecretService,
project_repository_access_candidate, project_repository_access_state,
};
use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
use crate::resource_broker::BackendResourceBroker;
use crate::runtime_settings::RuntimeConfigSchemaProvider;
use crate::runtime_subscription::RuntimeSubscriptionBroker;
use crate::skills;
use crate::store::{
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord,
TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryInsertOutcome,
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
WorkspaceResourceKind,
@@ -1898,6 +1900,12 @@ impl WorkspaceApi {
RepositoryRegistryReader::new(self.config.repositories.clone())
}
fn live_repository_reader(&self) -> Result<RepositoryRegistryReader> {
Ok(RepositoryRegistryReader::new(
load_configured_repositories_from_store(self.config_store.as_ref(), &self.config)?,
))
}
fn require_workspace_repository(&self, repository_id: &str) -> ApiResult<RepositoryRecord> {
self.store
.get_repository(&self.config.workspace_id, repository_id)?
@@ -2397,7 +2405,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
.route("/api/repositories", get(list_repositories))
.route(
"/api/w/{workspace_id}/repositories",
get(scoped_list_repositories),
get(scoped_list_repositories).post(scoped_create_repository),
)
.route("/api/repositories/{repository_id}", get(repository_detail))
.route(
@@ -3480,10 +3488,11 @@ async fn scoped_update_workspace_memory_settings(
}))
}
async fn require_manage_repository_secrets(
async fn require_workspace_owner(
api: &WorkspaceApi,
workspace_id: &str,
actor: &RequestActor,
permission: &str,
) -> ApiResult<()> {
validate_workspace_scope(api, workspace_id)?;
let workspace = api
@@ -3492,14 +3501,22 @@ async fn require_manage_repository_secrets(
.await?
.ok_or(Error::WorkspaceIdMismatch)?;
if workspace.owner_account_id.as_deref() != Some(actor.account_id.as_str()) {
return Err(Error::WorkspacePermissionDenied(
"ManageSecrets requires the Workspace owner account".to_string(),
)
return Err(Error::WorkspacePermissionDenied(format!(
"{permission} requires the Workspace owner account"
))
.into());
}
Ok(())
}
async fn require_manage_repository_secrets(
api: &WorkspaceApi,
workspace_id: &str,
actor: &RequestActor,
) -> ApiResult<()> {
require_workspace_owner(api, workspace_id, actor, "ManageSecrets").await
}
fn active_repository_access_projection(
api: &WorkspaceApi,
workspace_id: &str,
@@ -7888,6 +7905,108 @@ async fn scoped_list_repositories(
list_repositories(State(api)).await
}
async fn scoped_create_repository(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Extension(actor): Extension<RequestActor>,
Json(request): Json<CreateWorkspaceRepositoryRequest>,
) -> ApiResult<(StatusCode, Json<CreateWorkspaceRepositoryResponse>)> {
require_workspace_owner(&api, &path.workspace_id, &actor, "ManageRepositories").await?;
let repository_id = normalize_repository_identifier(&request.repository_id)?;
let display_name = normalize_repository_text(&request.display_name, "display_name", 256)?;
let default_ref = request
.default_ref
.as_deref()
.map(|value| normalize_repository_text(value, "default_ref", 512))
.transpose()?;
let source = parse_repository_source(&request.source)
.map_err(|error| Error::InvalidInput(format!("invalid repository source: {error}")))?;
let now = Utc::now().to_rfc3339();
let record = RepositoryRecord {
workspace_id: path.workspace_id.clone(),
repository_id: repository_id.clone(),
name: display_name,
kind: "git".to_string(),
provider: Some("git".to_string()),
source_fingerprint: repository_source_fingerprint(&source),
source,
default_ref,
source_revision: 1,
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
observed_at: None,
created_at: now.clone(),
updated_at: now,
};
let (status, replayed) = match api.config_store.insert_repository(&record)? {
RepositoryInsertOutcome::Created => (StatusCode::CREATED, false),
RepositoryInsertOutcome::Existing(existing)
if repository_create_intent_matches(&existing, &record) =>
{
(StatusCode::OK, true)
}
RepositoryInsertOutcome::Existing(_) => {
return Err(Error::RepositoryConflict(format!(
"repository_id {repository_id} already exists with different registration intent"
))
.into());
}
};
Ok((
status,
Json(CreateWorkspaceRepositoryResponse {
workspace_id: path.workspace_id,
repository_id,
replayed,
}),
))
}
fn normalize_repository_identifier(value: &str) -> Result<String> {
let value = value.trim();
if value.is_empty() || value.len() > 128 {
return Err(Error::InvalidInput(
"repository_id must contain 1..=128 characters".to_string(),
));
}
if !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err(Error::InvalidInput(
"repository_id must contain only ASCII letters, digits, '-', '_', or '.'".to_string(),
));
}
Ok(value.to_string())
}
fn normalize_repository_text(value: &str, field: &str, max_len: usize) -> Result<String> {
let value = value.trim();
if value.is_empty() || value.len() > max_len || value.chars().any(char::is_control) {
return Err(Error::InvalidInput(format!(
"{field} must contain 1..={max_len} non-control characters"
)));
}
Ok(value.to_string())
}
fn repository_create_intent_matches(
existing: &RepositoryRecord,
requested: &RepositoryRecord,
) -> bool {
existing.workspace_id == requested.workspace_id
&& existing.repository_id == requested.repository_id
&& existing.name == requested.name
&& existing.kind == requested.kind
&& existing.provider == requested.provider
&& existing.source == requested.source
&& existing.default_ref == requested.default_ref
&& existing.source_revision == requested.source_revision
&& existing.source_fingerprint == requested.source_fingerprint
}
async fn scoped_repository_detail(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRepositoryPath>,
@@ -11189,7 +11308,7 @@ async fn get_objective(
async fn list_repositories(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RepositoryListResponse>> {
let RepositoryListProjection { items, diagnostics } = api.repository_reader().list();
let RepositoryListProjection { items, diagnostics } = api.live_repository_reader()?.list();
Ok(Json(RepositoryListResponse {
workspace_id: api.config.workspace_id,
items,
@@ -11202,7 +11321,7 @@ async fn repository_detail(
State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>,
) -> ApiResult<Json<RepositoryDetailResponse>> {
let item = repository_lookup(api.repository_reader().summary(&repository_id))?;
let item = repository_lookup(api.live_repository_reader()?.summary(&repository_id))?;
Ok(Json(RepositoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
item,
@@ -11222,7 +11341,7 @@ async fn repository_log(
commits,
diagnostics,
} = repository_lookup(
api.repository_reader()
api.live_repository_reader()?
.recent_log(&repository_id, query.limit),
)?;
Ok(Json(RepositoryLogResponse {
@@ -15247,7 +15366,8 @@ impl IntoResponse for ApiError {
}
Error::TicketAssignmentConflict(_)
| Error::WorkdirAttachmentConflict(_)
| Error::WorkspaceConfigConflict(_) => StatusCode::CONFLICT,
| Error::WorkspaceConfigConflict(_)
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
StatusCode::BAD_REQUEST
@@ -17913,6 +18033,96 @@ mod tests {
);
}
let repositories_uri = format!("/api/w/{}/repositories", workspace.workspace.workspace_id);
let repository_request = serde_json::json!({
"repository_id": "documentation",
"display_name": "Documentation",
"source": temp.path().join("documentation").display().to_string(),
"default_ref": "main"
});
let create_repository = |body: serde_json::Value| {
Request::builder()
.method(Method::POST)
.uri(&repositories_uri)
.header(
axum::http::header::COOKIE,
"yoi_workspace_session=browser-session-auth",
)
.header(ORIGIN, &expected_origin)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
let created = app
.clone()
.oneshot(create_repository(repository_request.clone()))
.await
.unwrap();
assert_eq!(created.status(), StatusCode::CREATED);
let replayed = app
.clone()
.oneshot(create_repository(repository_request.clone()))
.await
.unwrap();
assert_eq!(replayed.status(), StatusCode::OK);
let conflict = app
.clone()
.oneshot(create_repository(serde_json::json!({
"repository_id": "documentation",
"display_name": "Different registration",
"source": temp.path().join("documentation").display().to_string(),
"default_ref": "main"
})))
.await
.unwrap();
assert_eq!(conflict.status(), StatusCode::CONFLICT);
let non_owner_create = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(&repositories_uri)
.header(
axum::http::header::AUTHORIZATION,
format!("Bearer {non_owner_token}"),
)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({
"repository_id": "foreign-owner",
"display_name": "Foreign owner",
"source": temp.path().join("foreign-owner").display().to_string()
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(non_owner_create.status(), StatusCode::FORBIDDEN);
let listed = app
.clone()
.oneshot(
Request::builder()
.uri(&repositories_uri)
.header(
axum::http::header::COOKIE,
"yoi_workspace_session=browser-session-auth",
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(listed.status(), StatusCode::OK);
let listed_body = axum::body::to_bytes(listed.into_body(), usize::MAX)
.await
.unwrap();
assert!(String::from_utf8_lossy(&listed_body).contains("documentation"));
let settings_uri = format!(
"/api/w/{}/settings/workspace",
workspace.workspace.workspace_id
@@ -23817,6 +24027,26 @@ mod tests {
})
.await
.unwrap();
let configured_repository = config.repositories[0].clone();
sqlite_store
.upsert_repository(&RepositoryRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
repository_id: configured_repository.id,
name: configured_repository
.display_name
.unwrap_or_else(|| "Test Repository".to_string()),
kind: "git".to_string(),
provider: Some(configured_repository.provider),
source: configured_repository.source,
default_ref: configured_repository.default_selector,
source_revision: configured_repository.source_revision,
source_fingerprint: configured_repository.source_fingerprint,
observed_status: configured_repository.observed_status,
observed_at: configured_repository.observed_at,
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
})
.unwrap();
let ticket_id = write_ticket(
&config.database_path,
TEST_WORKSPACE_ID,
+55
View File
@@ -326,6 +326,12 @@ pub struct RepositoryRecord {
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepositoryInsertOutcome {
Created,
Existing(RepositoryRecord),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceBootstrapRecord {
pub operation_key: String,
@@ -860,6 +866,7 @@ pub trait ControlPlaneStore: Send + Sync {
}
fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>>;
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()>;
fn insert_repository(&self, record: &RepositoryRecord) -> Result<RepositoryInsertOutcome>;
fn get_repository(
&self,
workspace_id: &str,
@@ -2175,6 +2182,54 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn insert_repository(&self, record: &RepositoryRecord) -> Result<RepositoryInsertOutcome> {
self.with_conn_mut(|conn| {
let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let existing = transaction
.query_row(
r#"SELECT workspace_id, repository_id, name, kind, provider,
source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories
WHERE workspace_id = ?1 AND repository_id = ?2"#,
params![record.workspace_id, record.repository_id],
read_repository_record,
)
.optional()?;
if let Some(existing) = existing {
transaction.commit()?;
return Ok(RepositoryInsertOutcome::Existing(existing));
}
transaction.execute(
r#"INSERT INTO repositories (
workspace_id, repository_id, name, kind, provider, uri,
source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#,
params![
record.workspace_id,
record.repository_id,
record.name,
record.kind,
record.provider,
record.source.uri,
record.source.kind.as_str(),
record.source.uri,
record.default_ref,
record.source_revision,
record.source_fingerprint,
record.observed_status.as_str(),
record.observed_at,
record.created_at,
record.updated_at,
],
)?;
transaction.commit()?;
Ok(RepositoryInsertOutcome::Created)
})
}
fn get_repository(
&self,
workspace_id: &str,