diff --git a/.env.example b/.env.example index b4c4775..0b64736 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ POSTGRES_DB=crank POSTGRES_USER=crank POSTGRES_PASSWORD=change-me CRANK_DATABASE_URL=postgres://crank:change-me@postgres:5432/crank +CRANK_ADMIN_API_IMAGE=crank/admin-api:dev +CRANK_MCP_SERVER_IMAGE=crank/mcp-server:dev +CRANK_UI_IMAGE=crank/ui:dev CRANK_STORAGE_ROOT=/var/lib/crank/storage CRANK_PUBLISH_HOST=127.0.0.1 CRANK_ADMIN_BIND=0.0.0.0:3001 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 328aa98..4623f73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: run: docker build -f apps/ui/Dockerfile . deployment: - name: Deployment Artifacts + name: Deployment Manifests runs-on: ubuntu-latest steps: @@ -84,6 +84,3 @@ jobs: - name: Validate docker compose run: docker compose config -q - - - name: Build deployable images - run: docker compose build admin-api mcp-server ui diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4b784d5..2f6f991 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,8 +12,77 @@ concurrency: group: deploy-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }} cancel-in-progress: true +permissions: + contents: read + packages: write + +env: + IMAGE_TAG: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + ADMIN_API_IMAGE: ghcr.io/${{ github.repository_owner }}/crank-admin-api + MCP_SERVER_IMAGE: ghcr.io/${{ github.repository_owner }}/crank-mcp-server + UI_IMAGE: ghcr.io/${{ github.repository_owner }}/crank-ui + jobs: + build-images: + if: > + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' + ) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: admin-api + dockerfile: apps/admin-api/Dockerfile + - name: mcp-server + dockerfile: apps/mcp-server/Dockerfile + - name: ui + dockerfile: apps/ui/Dockerfile + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + + - name: Resolve image name + id: image + run: | + case "${{ matrix.name }}" in + admin-api) echo "name=${ADMIN_API_IMAGE}" >> "$GITHUB_OUTPUT" ;; + mcp-server) echo "name=${MCP_SERVER_IMAGE}" >> "$GITHUB_OUTPUT" ;; + ui) echo "name=${UI_IMAGE}" >> "$GITHUB_OUTPUT" ;; + *) exit 1 ;; + esac + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push ${{ matrix.name }} + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: | + ${{ steps.image.outputs.name }}:${{ env.IMAGE_TAG }} + ${{ steps.image.outputs.name }}:main + cache-from: type=gha,scope=${{ matrix.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.name }} + deploy: + needs: build-images if: > github.event_name == 'workflow_dispatch' || ( @@ -41,28 +110,32 @@ jobs: printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts - - name: Sync project to server + - name: Sync deployment files to server run: | - rsync -az --delete \ - --exclude ".git" \ - --exclude "target" \ - --exclude "notes" \ - --exclude "node_modules" \ - ./ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/" + ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + "mkdir -p '${{ secrets.DEPLOY_PATH }}'" + rsync -az docker-compose.yml \ + "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/docker-compose.yml" - name: Write environment file run: | - ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ - "mkdir -p '${{ secrets.DEPLOY_PATH }}' && cat > '${{ secrets.DEPLOY_PATH }}/.env'" \ - <<< "${{ secrets.DEPLOY_ENV_FILE }}" + { + printf '%s\n' "${{ secrets.DEPLOY_ENV_FILE }}" + printf 'CRANK_ADMIN_API_IMAGE=%s:%s\n' "${{ env.ADMIN_API_IMAGE }}" "${{ env.IMAGE_TAG }}" + printf 'CRANK_MCP_SERVER_IMAGE=%s:%s\n' "${{ env.MCP_SERVER_IMAGE }}" "${{ env.IMAGE_TAG }}" + printf 'CRANK_UI_IMAGE=%s:%s\n' "${{ env.UI_IMAGE }}" "${{ env.IMAGE_TAG }}" + } | ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + "mkdir -p '${{ secrets.DEPLOY_PATH }}' && cat > '${{ secrets.DEPLOY_PATH }}/.env'" - name: Deploy with Docker Compose run: | ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" " set -e cd '${{ secrets.DEPLOY_PATH }}' + echo '${{ secrets.DEPLOY_REGISTRY_TOKEN }}' | docker login ghcr.io -u '${{ secrets.DEPLOY_REGISTRY_USER }}' --password-stdin docker compose config -q - docker compose up -d --build --remove-orphans + docker compose pull admin-api mcp-server ui + docker compose up -d --remove-orphans " - name: Verify health endpoints diff --git a/README.md b/README.md index 9c6d41c..162e683 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ Crank - платформа для публикации внешних API в в В репозитории настроены: -- `CI` для Rust, UI container и deployment artifacts; -- `CD`, который запускается после успешного `CI` на `main` или вручную; +- `CI` для Rust, UI и deployment manifests; +- `CD`, который после успешного `CI` на `main` собирает versioned images, пушит их в `GHCR` и деплоит через `docker compose pull`; - containerized deployment через `docker compose`. ## Поддерживаемые протоколы diff --git a/TASKS.md b/TASKS.md index 63d1bc2..8980392 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,19 +2,18 @@ ## Current -### `feat/settings-live-flow` +### `feat/cd-image-pipeline` -Status: completed +Status: in_progress DoD: -- `apps/ui/html/settings.html` и `apps/ui/js/settings.js` используют live backend для `profile` и `password` -- `profile` перестает читать данные из `localStorage` как source of truth -- security-block больше не притворяется готовой интеграцией там, где backend-контракта еще нет -- `test-ui` остается нетронутым как fallback +- CI больше не собирает deployable images без использования результата +- Deploy собирает и пушит versioned images в registry с cache +- production deploy использует `docker compose pull && docker compose up -d`, без server-side build +- docs и runtime config описывают registry-based rollout ## Next -- `feat/workspace-access-polish` - `feat/alpine-polish` ## Backlog diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 25a38b8..3de6106 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -7,8 +7,9 @@ use crate::{ auth::{require_session, require_workspace_session}, routes::{ access::{ - create_invitation, create_platform_api_key, delete_invitation, delete_platform_api_key, - list_invitations, list_memberships, list_platform_api_keys, revoke_platform_api_key, + create_invitation, create_platform_api_key, delete_invitation, delete_membership, + delete_platform_api_key, delete_workspace, export_workspace, list_invitations, + list_memberships, list_platform_api_keys, revoke_platform_api_key, update_membership, }, agents::{ create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent, @@ -94,11 +95,16 @@ pub fn build_app(state: AppState) -> Router { ) .route("/auth-profiles/{auth_profile_id}", get(get_auth_profile)) .route("/members", get(list_memberships)) + .route( + "/members/{user_id}", + axum::routing::patch(update_membership).delete(delete_membership), + ) .route( "/invitations", get(list_invitations).post(create_invitation), ) .route("/invitations/{invitation_id}", delete(delete_invitation)) + .route("/export", get(export_workspace)) .route( "/platform-api-keys", get(list_platform_api_keys).post(create_platform_api_key), @@ -127,7 +133,9 @@ pub fn build_app(state: AppState) -> Router { let workspace_scoped_router = Router::new() .route( "/workspaces/{workspace_id}", - get(get_workspace).patch(update_workspace), + get(get_workspace) + .patch(update_workspace) + .delete(delete_workspace), ) .nest("/workspaces/{workspace_id}", workspace_router) .layer(middleware::from_fn_with_state( @@ -765,6 +773,89 @@ mod tests { assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn manages_workspace_access_lifecycle() { + let registry = test_registry().await; + let storage_root = test_storage_root("workspace_access"); + let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; + let client = authorized_client(&base_url).await; + + let second_user_id = registry + .upsert_bootstrap_user("operator-2@crank.local", "Operator Two", "external-hash") + .await + .unwrap(); + registry + .ensure_membership( + &WorkspaceId::new(DEFAULT_WORKSPACE_ID), + &second_user_id, + MembershipRole::Viewer, + ) + .await + .unwrap(); + + let updated_members = assert_success_json( + client + .patch(format!("{base_url}/members/{}", second_user_id.as_str())) + .json(&json!({ "role": "admin" })) + .send() + .await + .unwrap(), + ) + .await; + let updated_member = updated_members["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["user"]["id"] == second_user_id.as_str()) + .unwrap(); + assert_eq!(updated_member["role"], "admin"); + + let exported = assert_success_json( + client + .get(format!("{base_url}/export")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!( + exported["workspace"]["workspace"]["id"], + DEFAULT_WORKSPACE_ID + ); + assert_eq!(exported["memberships"].as_array().unwrap().len(), 2); + + let delete_member_status = client + .delete(format!("{base_url}/members/{}", second_user_id.as_str())) + .send() + .await + .unwrap() + .status(); + assert_eq!(delete_member_status, reqwest::StatusCode::NO_CONTENT); + + let delete_workspace_status = client + .delete(base_url.as_ref()) + .send() + .await + .unwrap() + .status(); + assert_eq!(delete_workspace_status, reqwest::StatusCode::NO_CONTENT); + + let root_url = base_url + .as_ref() + .split("/api/admin/workspaces/") + .next() + .unwrap(); + let missing_workspace = client + .get(format!( + "{root_url}/api/admin/workspaces/{DEFAULT_WORKSPACE_ID}" + )) + .send() + .await + .unwrap(); + assert_eq!(missing_workspace.status(), reqwest::StatusCode::FORBIDDEN); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn updates_profile_and_changes_password() { diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 17e680b..9018099 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -124,6 +124,12 @@ impl From for ApiError { RegistryError::UserNotFound { user_id } => { Self::not_found(format!("user {user_id} was not found")) } + RegistryError::MembershipNotFound { + workspace_id, + user_id, + } => Self::not_found(format!( + "membership for user {user_id} in workspace {workspace_id} was not found" + )), RegistryError::AgentNotFound { agent_id } => { Self::not_found(format!("agent {agent_id} was not found")) } diff --git a/apps/admin-api/src/routes/access.rs b/apps/admin-api/src/routes/access.rs index 79edb45..d8774a2 100644 --- a/apps/admin-api/src/routes/access.rs +++ b/apps/admin-api/src/routes/access.rs @@ -1,5 +1,5 @@ use axum::{ - Json, + Extension, Json, extract::{Path, State}, http::StatusCode, }; @@ -7,8 +7,9 @@ use serde::Deserialize; use serde_json::{Value, json}; use crate::{ + auth::AuthenticatedSession, error::ApiError, - service::{InvitationPayload, PlatformApiKeyPayload}, + service::{InvitationPayload, PlatformApiKeyPayload, UpdateMembershipPayload}, state::AppState, }; @@ -23,6 +24,12 @@ pub struct WorkspaceInvitationPath { pub invitation_id: String, } +#[derive(Deserialize)] +pub struct WorkspaceMembershipPath { + pub workspace_id: String, + pub user_id: String, +} + #[derive(Deserialize)] pub struct WorkspacePlatformApiKeyPath { pub workspace_id: String, @@ -40,6 +47,40 @@ pub async fn list_memberships( Ok(Json(json!({ "items": items }))) } +pub async fn update_membership( + Path(path): Path, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let items = state + .service + .update_membership_role( + &path.workspace_id.as_str().into(), + &session.user.id, + &path.user_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn delete_membership( + Path(path): Path, + State(state): State, + Extension(session): Extension, +) -> Result { + state + .service + .remove_membership( + &path.workspace_id.as_str().into(), + &session.user.id, + &path.user_id.as_str().into(), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + pub async fn list_invitations( Path(path): Path, State(state): State, @@ -127,3 +168,26 @@ pub async fn delete_platform_api_key( .await?; Ok(StatusCode::NO_CONTENT) } + +pub async fn export_workspace( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let exported = state + .service + .export_workspace(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!(exported))) +} + +pub async fn delete_workspace( + Path(path): Path, + State(state): State, + Extension(session): Extension, +) -> Result { + state + .service + .delete_workspace(&path.workspace_id.as_str().into(), &session.user.id) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index c6daf10..750a6ec 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -219,6 +219,11 @@ pub struct InvitationPayload { pub expires_at: Option, } +#[derive(Clone, Debug, Deserialize)] +pub struct UpdateMembershipPayload { + pub role: MembershipRole, +} + #[derive(Clone, Debug, Serialize)] pub struct CreatedInvitationResponse { pub invitation: InvitationRecord, @@ -237,6 +242,17 @@ pub struct CreatedPlatformApiKeyResponse { pub secret: String, } +#[derive(Clone, Debug, Serialize)] +pub struct WorkspaceExportResponse { + pub workspace: WorkspaceRecord, + pub memberships: Vec, + pub invitations: Vec, + pub operations: Vec, + pub agents: Vec, + pub platform_api_keys: Vec, + pub exported_at: String, +} + #[derive(Clone, Debug, Deserialize)] pub struct LogsQuery { pub level: Option, @@ -746,6 +762,107 @@ impl AdminService { Ok(self.registry.list_memberships(workspace_id).await?) } + pub async fn update_membership_role( + &self, + workspace_id: &WorkspaceId, + actor_user_id: &crank_core::UserId, + target_user_id: &crank_core::UserId, + payload: UpdateMembershipPayload, + ) -> Result, ApiError> { + let memberships = self.list_memberships(workspace_id).await?; + let actor_membership = memberships + .iter() + .find(|membership| &membership.user.id == actor_user_id) + .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; + let target_membership = memberships + .iter() + .find(|membership| &membership.user.id == target_user_id) + .ok_or_else(|| { + ApiError::not_found(format!( + "membership for user {} in workspace {} was not found", + target_user_id.as_str(), + workspace_id.as_str() + )) + })?; + + if !matches!( + actor_membership.role, + MembershipRole::Owner | MembershipRole::Admin + ) { + return Err(ApiError::forbidden( + "only owners and admins can manage workspace members", + )); + } + + if matches!(target_membership.role, MembershipRole::Owner) + && !matches!(payload.role, MembershipRole::Owner) + { + let owner_count = memberships + .iter() + .filter(|membership| matches!(membership.role, MembershipRole::Owner)) + .count(); + if owner_count <= 1 { + return Err(ApiError::validation( + "workspace must keep at least one owner", + )); + } + } + + self.registry + .update_membership_role(workspace_id, target_user_id, payload.role) + .await?; + self.list_memberships(workspace_id).await + } + + pub async fn remove_membership( + &self, + workspace_id: &WorkspaceId, + actor_user_id: &crank_core::UserId, + target_user_id: &crank_core::UserId, + ) -> Result<(), ApiError> { + let memberships = self.list_memberships(workspace_id).await?; + let actor_membership = memberships + .iter() + .find(|membership| &membership.user.id == actor_user_id) + .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; + let target_membership = memberships + .iter() + .find(|membership| &membership.user.id == target_user_id) + .ok_or_else(|| { + ApiError::not_found(format!( + "membership for user {} in workspace {} was not found", + target_user_id.as_str(), + workspace_id.as_str() + )) + })?; + + if !matches!( + actor_membership.role, + MembershipRole::Owner | MembershipRole::Admin + ) { + return Err(ApiError::forbidden( + "only owners and admins can manage workspace members", + )); + } + + if matches!(target_membership.role, MembershipRole::Owner) { + let owner_count = memberships + .iter() + .filter(|membership| matches!(membership.role, MembershipRole::Owner)) + .count(); + if owner_count <= 1 { + return Err(ApiError::validation( + "workspace must keep at least one owner", + )); + } + } + + self.registry + .delete_membership(workspace_id, target_user_id) + .await?; + Ok(()) + } + #[instrument(skip(self))] pub async fn list_invitations( &self, @@ -804,6 +921,63 @@ impl AdminService { Ok(()) } + pub async fn export_workspace( + &self, + workspace_id: &WorkspaceId, + ) -> Result { + let workspace = self.get_workspace(workspace_id).await?; + let memberships = self.list_memberships(workspace_id).await?; + let invitations = self + .list_invitations(workspace_id) + .await? + .into_iter() + .map(|record| { + json!({ + "id": record.invitation.id, + "email": record.invitation.email, + "role": record.invitation.role, + "status": record.invitation.status, + "expires_at": record.invitation.expires_at, + "created_at": record.invitation.created_at, + }) + }) + .collect(); + let operations = self.list_operations(workspace_id).await?; + let agents = self.list_agents(workspace_id).await?; + let platform_api_keys = self.list_platform_api_keys(workspace_id).await?; + + Ok(WorkspaceExportResponse { + workspace, + memberships, + invitations, + operations, + agents, + platform_api_keys, + exported_at: now_string()?, + }) + } + + pub async fn delete_workspace( + &self, + workspace_id: &WorkspaceId, + actor_user_id: &crank_core::UserId, + ) -> Result<(), ApiError> { + let memberships = self.list_memberships(workspace_id).await?; + let actor_membership = memberships + .iter() + .find(|membership| &membership.user.id == actor_user_id) + .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; + + if !matches!(actor_membership.role, MembershipRole::Owner) { + return Err(ApiError::forbidden( + "only workspace owners can delete a workspace", + )); + } + + self.registry.delete_workspace(workspace_id).await?; + Ok(()) + } + #[instrument(skip(self))] pub async fn list_platform_api_keys( &self, diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html index 4374132..070d735 100644 --- a/apps/ui/html/workspace-setup.html +++ b/apps/ui/html/workspace-setup.html @@ -304,7 +304,7 @@
Export all data
-
Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON.
+
Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 4e7b7ea..8c2bbfa 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -177,6 +177,12 @@ listMemberships: function(workspaceId) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/members'); }, + updateMembership: function(workspaceId, userId, payload) { + return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/members/' + encodeURIComponent(userId), payload); + }, + deleteMembership: function(workspaceId, userId) { + return del('/workspaces/' + encodeURIComponent(workspaceId) + '/members/' + encodeURIComponent(userId)); + }, listInvitations: function(workspaceId) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations'); }, @@ -186,6 +192,12 @@ deleteInvitation: function(workspaceId, invitationId) { return del('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations/' + encodeURIComponent(invitationId)); }, + exportWorkspace: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/export'); + }, + deleteWorkspace: function(workspaceId) { + return del('/workspaces/' + encodeURIComponent(workspaceId)); + }, listOperations: function(workspaceId) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations'); }, diff --git a/apps/ui/js/workspace-setup.js b/apps/ui/js/workspace-setup.js index 81156c7..2762c5b 100644 --- a/apps/ui/js/workspace-setup.js +++ b/apps/ui/js/workspace-setup.js @@ -7,6 +7,7 @@ var workspaceFormState = { workspaceRecord: null, memberships: [], invitations: [], + currentUserId: null, }; function normalizeSlug(value) { @@ -101,16 +102,32 @@ function applyWorkspaceRecord(record) { function addMemberRow(container, membership) { var row = document.createElement('div'); row.className = 'member-row'; + var isCurrentUser = membership.user.id === workspaceFormState.currentUserId; + var canManage = membership.role !== 'owner'; row.innerHTML = '
' + membership.user.display_name.slice(0, 2).toUpperCase() + '
' + '
' + - '
' + membership.user.display_name + '
' + + '
' + membership.user.display_name + + (isCurrentUser ? ' (you)' : '') + + '
' + '
' + membership.user.email + '
' + '
' + '
' + - '' + roleLabel(membership.role) + ''; + ( + canManage + ? '' + + '' + : '' + roleLabel(membership.role) + '' + ); container.appendChild(row); } @@ -175,6 +192,13 @@ async function loadWorkspaceAccessData() { return; } + if (window.CrankAuth && typeof window.CrankAuth.fetchSession === 'function') { + try { + var session = await window.CrankAuth.fetchSession(false); + workspaceFormState.currentUserId = session && session.user ? session.user.id : null; + } catch (_error) {} + } + var membershipsResponse = await window.CrankApi.listMemberships(workspaceFormState.workspaceId); var invitationsResponse = await window.CrankApi.listInvitations(workspaceFormState.workspaceId); workspaceFormState.memberships = membershipsResponse && membershipsResponse.items ? membershipsResponse.items : []; @@ -384,12 +408,42 @@ async function sendInvite() { } } -function updateRole() { - alert('Role management is not available yet in the backend.'); +async function updateRole(select, userId) { + if (!workspaceFormState.workspaceId || !window.CrankApi) { + return; + } + + var previous = select.dataset.previous || select.value; + select.disabled = true; + + try { + var response = await window.CrankApi.updateMembership(workspaceFormState.workspaceId, userId, { + role: select.value, + }); + workspaceFormState.memberships = response && response.items ? response.items : workspaceFormState.memberships; + renderMembers(); + } catch (error) { + select.value = previous; + alert(error.message || 'Failed to update role'); + } finally { + select.disabled = false; + } } -function removeMember() { - alert('Member removal is not available yet in the backend.'); +async function removeMember(userId, name) { + if (!workspaceFormState.workspaceId || !window.CrankApi) { + return; + } + if (!confirm('Remove ' + name + ' from this workspace?')) { + return; + } + + try { + await window.CrankApi.deleteMembership(workspaceFormState.workspaceId, userId); + await loadWorkspaceAccessData(); + } catch (error) { + alert(error.message || 'Failed to remove member'); + } } async function revokeInviteById(invitationId) { @@ -413,6 +467,48 @@ function revokeInvite(button) { row.remove(); } +function downloadJsonFile(fileName, value) { + var blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + +async function exportWorkspaceSnapshot() { + if (!workspaceFormState.workspaceId || !window.CrankApi) { + return; + } + + try { + var snapshot = await window.CrankApi.exportWorkspace(workspaceFormState.workspaceId); + var slug = workspaceFormState.workspaceRecord && workspaceFormState.workspaceRecord.workspace + ? workspaceFormState.workspaceRecord.workspace.slug + : 'workspace'; + downloadJsonFile(slug + '-snapshot.json', snapshot); + } catch (error) { + alert(error.message || 'Failed to export workspace'); + } +} + +async function deleteWorkspaceAction() { + if (!workspaceFormState.workspaceId || !window.CrankApi || !window.CrankAuth) { + return; + } + if (!confirm('Delete this workspace? This cannot be undone.')) { + return; + } + + try { + await window.CrankApi.deleteWorkspace(workspaceFormState.workspaceId); + await window.CrankAuth.logout(); + } catch (error) { + alert(error.message || 'Failed to delete workspace'); + } +} + async function initPage() { updatePageMode(); @@ -421,13 +517,8 @@ async function initPage() { button.classList.toggle('active', button.dataset.lang === language); }); - document.getElementById('export-workspace-btn').addEventListener('click', function () { - alert('Workspace export is not available yet in the backend.'); - }); - - document.getElementById('delete-workspace-btn').addEventListener('click', function () { - alert('Workspace deletion is not available yet in the backend.'); - }); + document.getElementById('export-workspace-btn').addEventListener('click', exportWorkspaceSnapshot); + document.getElementById('delete-workspace-btn').addEventListener('click', deleteWorkspaceAction); if (workspaceFormState.isCreateMode) { document.getElementById('ws-name').focus(); diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index df0d8c7..8089561 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -14,6 +14,11 @@ pub enum RegistryError { UserNotFound { user_id: String }, #[error("user with email {email} already exists")] UserEmailAlreadyExists { email: String }, + #[error("membership for user {user_id} in workspace {workspace_id} was not found")] + MembershipNotFound { + workspace_id: String, + user_id: String, + }, #[error("invitation {invitation_id} was not found")] InvitationNotFound { invitation_id: String }, #[error("platform api key {key_id} was not found")] diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index 25805e5..9fc38a3 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -415,6 +415,57 @@ impl PostgresRegistry { rows.iter().map(map_membership_record).collect() } + pub async fn update_membership_role( + &self, + workspace_id: &WorkspaceId, + user_id: &UserId, + role: MembershipRole, + ) -> Result<(), RegistryError> { + let result = sqlx::query( + "update memberships + set role = $3 + where workspace_id = $1 and user_id = $2", + ) + .bind(workspace_id.as_str()) + .bind(user_id.as_str()) + .bind(serialize_enum_text(&role, "role")?) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::MembershipNotFound { + workspace_id: workspace_id.as_str().to_owned(), + user_id: user_id.as_str().to_owned(), + }); + } + + Ok(()) + } + + pub async fn delete_membership( + &self, + workspace_id: &WorkspaceId, + user_id: &UserId, + ) -> Result<(), RegistryError> { + let result = sqlx::query( + "delete from memberships + where workspace_id = $1 and user_id = $2", + ) + .bind(workspace_id.as_str()) + .bind(user_id.as_str()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::MembershipNotFound { + workspace_id: workspace_id.as_str().to_owned(), + user_id: user_id.as_str().to_owned(), + }); + } + + Ok(()) + } + pub async fn list_invitations( &self, workspace_id: &WorkspaceId, @@ -493,6 +544,21 @@ impl PostgresRegistry { Ok(()) } + pub async fn delete_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), RegistryError> { + let result = sqlx::query("delete from workspaces where id = $1") + .bind(workspace_id.as_str()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::WorkspaceNotFound { + workspace_id: workspace_id.as_str().to_owned(), + }); + } + + Ok(()) + } + pub async fn list_platform_api_keys( &self, workspace_id: &WorkspaceId, diff --git a/docker-compose.yml b/docker-compose.yml index e86e167..08d5e74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: retries: 5 admin-api: + image: ${CRANK_ADMIN_API_IMAGE:-crank/admin-api:dev} build: context: . dockerfile: apps/admin-api/Dockerfile @@ -48,6 +49,7 @@ services: retries: 5 mcp-server: + image: ${CRANK_MCP_SERVER_IMAGE:-crank/mcp-server:dev} build: context: . dockerfile: apps/mcp-server/Dockerfile @@ -74,6 +76,7 @@ services: retries: 5 ui: + image: ${CRANK_UI_IMAGE:-crank/ui:dev} build: context: . dockerfile: apps/ui/Dockerfile diff --git a/docs/admin-api.md b/docs/admin-api.md index 113a638..09d59c8 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -51,15 +51,23 @@ - `POST /api/admin/workspaces` - `GET /api/admin/workspaces/{workspace_id}` - `PATCH /api/admin/workspaces/{workspace_id}` +- `DELETE /api/admin/workspaces/{workspace_id}` - `GET /api/admin/workspaces/{workspace_id}/members` +- `PATCH /api/admin/workspaces/{workspace_id}/members/{user_id}` +- `DELETE /api/admin/workspaces/{workspace_id}/members/{user_id}` - `GET /api/admin/workspaces/{workspace_id}/invitations` - `POST /api/admin/workspaces/{workspace_id}/invitations` - `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}` +- `GET /api/admin/workspaces/{workspace_id}/export` Контракт: - `POST /invitations` возвращает metadata invitation и одноразовый `invite_token`; -- `invite_token` доступен только в create-response и не возвращается повторно в list endpoints. +- `invite_token` доступен только в create-response и не возвращается повторно в list endpoints; +- `PATCH /members/{user_id}` меняет роль участника; +- `DELETE /members/{user_id}` удаляет участника из workspace; +- `GET /export` возвращает JSON snapshot workspace lifecycle-данных; +- `DELETE /workspaces/{workspace_id}` разрешен только `owner`. ### 5.2. Auth and session diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md index 0105d2a..61e775c 100644 --- a/docs/alpine-ui-integration-plan.md +++ b/docs/alpine-ui-integration-plan.md @@ -302,27 +302,31 @@ UI-файлы: - `GET /api/admin/workspaces/{workspace_id}` - `PATCH /api/admin/workspaces/{workspace_id}` - `GET /api/admin/workspaces/{workspace_id}/members` +- `PATCH /api/admin/workspaces/{workspace_id}/members/{user_id}` +- `DELETE /api/admin/workspaces/{workspace_id}/members/{user_id}` - `GET /api/admin/workspaces/{workspace_id}/invitations` - `POST /api/admin/workspaces/{workspace_id}/invitations` - `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}` +- `GET /api/admin/workspaces/{workspace_id}/export` +- `DELETE /api/admin/workspaces/{workspace_id}` Что еще не хватает: - `switch current workspace` все еще живет на клиенте, а не в session/backend; -- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать; -- delete/export workspace lifecycle пока не реализован на backend +- session-aware current workspace model; +- finer-grained permission matrix beyond current `owner/admin` management rules. Отдельный конфликт: - current workspace по-прежнему client-side; -- memberships и invitations уже live, но role-management пока read-only; +- memberships, role-management и invitations уже live; - `settings` page не должна дублировать этот flow, пока у нее нет своего backend-контракта Простой итог: - `workspace-setup` уже подключен к live backend; -- create/edit workspace, refresh списка workspace и invitations работают; -- role management и workspace deletion остаются отдельным следующим этапом. +- create/edit workspace, memberships, invitations, export и delete работают; +- текущий workspace все еще остается client-side моделью. ### 4.8. Settings diff --git a/docs/deployment.md b/docs/deployment.md index b963c27..5b8a066 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -199,22 +199,37 @@ CI должен выполнять: CD для MVP должен: - запускаться только после успешного `CI` на `main` или вручную; -- собирать production artifacts; +- собирать versioned container images; +- пушить их в container registry с cache; - доставлять deployment files на целевой Linux host; -- выполнять controlled restart; +- выполнять controlled restart через `pull`, без сборки на сервере; - проверять health endpoints после запуска. Минимальный flow: 1. push в `main` 2. успешный `CI` -3. build images или удаленная сборка на хосте +3. build and push images в registry 4. upload env и deployment files -5. `docker compose config -q` -6. `docker compose up -d` -7. healthcheck verification для `ui`, `admin-api`, `mcp-server` +5. `docker login` на registry на целевом хосте +6. `docker compose config -q` +7. `docker compose pull` +8. `docker compose up -d` +9. healthcheck verification для `ui`, `admin-api`, `mcp-server` -Для MVP допустим deployment с удаленной сборкой на целевом Linux host через `ssh + rsync + docker compose up -d --build`, если не используется отдельный container registry. +Для текущего проекта базовая рекомендуемая схема: + +- `GHCR` как registry; +- versioned tags по `git sha`; +- `docker/buildx` cache в GitHub Actions; +- на сервере только `pull + restart`, без `docker compose up --build`. + +Для private registry на сервере нужны отдельные deployment secrets: + +- `DEPLOY_REGISTRY_USER` +- `DEPLOY_REGISTRY_TOKEN` + +Они используются только для `docker login` на целевом Linux host перед `docker compose pull`. ## 12. Что нельзя забыть diff --git a/docs/runtime-config.md b/docs/runtime-config.md index 0bc39f3..8245734 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -58,6 +58,9 @@ var/crank/ Минимально ожидаются: - `CRANK_DATABASE_URL` +- `CRANK_ADMIN_API_IMAGE` +- `CRANK_MCP_SERVER_IMAGE` +- `CRANK_UI_IMAGE` - `CRANK_STORAGE_ROOT` - `CRANK_ADMIN_BIND` - `CRANK_MCP_BIND` @@ -116,6 +119,7 @@ Demo/deployment: - включенная app-level auth-защита admin-api; - стабильный `Streamable HTTP` endpoint для MCP. - containerized runtime через `Docker` и `docker-compose`. +- registry-backed image rollout через `GHCR` или совместимый registry. ### Auth env