merge: cd image pipeline
This commit is contained in:
@@ -2,6 +2,9 @@ POSTGRES_DB=crank
|
|||||||
POSTGRES_USER=crank
|
POSTGRES_USER=crank
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
CRANK_DATABASE_URL=postgres://crank:change-me@postgres:5432/crank
|
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_STORAGE_ROOT=/var/lib/crank/storage
|
||||||
CRANK_PUBLISH_HOST=127.0.0.1
|
CRANK_PUBLISH_HOST=127.0.0.1
|
||||||
CRANK_ADMIN_BIND=0.0.0.0:3001
|
CRANK_ADMIN_BIND=0.0.0.0:3001
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
run: docker build -f apps/ui/Dockerfile .
|
run: docker build -f apps/ui/Dockerfile .
|
||||||
|
|
||||||
deployment:
|
deployment:
|
||||||
name: Deployment Artifacts
|
name: Deployment Manifests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -84,6 +84,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Validate docker compose
|
- name: Validate docker compose
|
||||||
run: docker compose config -q
|
run: docker compose config -q
|
||||||
|
|
||||||
- name: Build deployable images
|
|
||||||
run: docker compose build admin-api mcp-server ui
|
|
||||||
|
|||||||
@@ -12,8 +12,77 @@ concurrency:
|
|||||||
group: deploy-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
|
group: deploy-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
|
||||||
cancel-in-progress: true
|
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:
|
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:
|
deploy:
|
||||||
|
needs: build-images
|
||||||
if: >
|
if: >
|
||||||
github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
(
|
(
|
||||||
@@ -41,28 +110,32 @@ jobs:
|
|||||||
printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
|
printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
|
||||||
chmod 644 ~/.ssh/known_hosts
|
chmod 644 ~/.ssh/known_hosts
|
||||||
|
|
||||||
- name: Sync project to server
|
- name: Sync deployment files to server
|
||||||
run: |
|
run: |
|
||||||
rsync -az --delete \
|
ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
||||||
--exclude ".git" \
|
"mkdir -p '${{ secrets.DEPLOY_PATH }}'"
|
||||||
--exclude "target" \
|
rsync -az docker-compose.yml \
|
||||||
--exclude "notes" \
|
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/docker-compose.yml"
|
||||||
--exclude "node_modules" \
|
|
||||||
./ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/"
|
|
||||||
|
|
||||||
- name: Write environment file
|
- name: Write environment file
|
||||||
run: |
|
run: |
|
||||||
ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
{
|
||||||
"mkdir -p '${{ secrets.DEPLOY_PATH }}' && cat > '${{ secrets.DEPLOY_PATH }}/.env'" \
|
printf '%s\n' "${{ secrets.DEPLOY_ENV_FILE }}"
|
||||||
<<< "${{ 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
|
- name: Deploy with Docker Compose
|
||||||
run: |
|
run: |
|
||||||
ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "
|
ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "
|
||||||
set -e
|
set -e
|
||||||
cd '${{ secrets.DEPLOY_PATH }}'
|
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 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
|
- name: Verify health endpoints
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ Crank - платформа для публикации внешних API в в
|
|||||||
|
|
||||||
В репозитории настроены:
|
В репозитории настроены:
|
||||||
|
|
||||||
- `CI` для Rust, UI container и deployment artifacts;
|
- `CI` для Rust, UI и deployment manifests;
|
||||||
- `CD`, который запускается после успешного `CI` на `main` или вручную;
|
- `CD`, который после успешного `CI` на `main` собирает versioned images, пушит их в `GHCR` и деплоит через `docker compose pull`;
|
||||||
- containerized deployment через `docker compose`.
|
- containerized deployment через `docker compose`.
|
||||||
|
|
||||||
## Поддерживаемые протоколы
|
## Поддерживаемые протоколы
|
||||||
|
|||||||
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/settings-live-flow`
|
### `feat/cd-image-pipeline`
|
||||||
|
|
||||||
Status: completed
|
Status: in_progress
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- `apps/ui/html/settings.html` и `apps/ui/js/settings.js` используют live backend для `profile` и `password`
|
- CI больше не собирает deployable images без использования результата
|
||||||
- `profile` перестает читать данные из `localStorage` как source of truth
|
- Deploy собирает и пушит versioned images в registry с cache
|
||||||
- security-block больше не притворяется готовой интеграцией там, где backend-контракта еще нет
|
- production deploy использует `docker compose pull && docker compose up -d`, без server-side build
|
||||||
- `test-ui` остается нетронутым как fallback
|
- docs и runtime config описывают registry-based rollout
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/workspace-access-polish`
|
|
||||||
- `feat/alpine-polish`
|
- `feat/alpine-polish`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ use crate::{
|
|||||||
auth::{require_session, require_workspace_session},
|
auth::{require_session, require_workspace_session},
|
||||||
routes::{
|
routes::{
|
||||||
access::{
|
access::{
|
||||||
create_invitation, create_platform_api_key, delete_invitation, delete_platform_api_key,
|
create_invitation, create_platform_api_key, delete_invitation, delete_membership,
|
||||||
list_invitations, list_memberships, list_platform_api_keys, revoke_platform_api_key,
|
delete_platform_api_key, delete_workspace, export_workspace, list_invitations,
|
||||||
|
list_memberships, list_platform_api_keys, revoke_platform_api_key, update_membership,
|
||||||
},
|
},
|
||||||
agents::{
|
agents::{
|
||||||
create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent,
|
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("/auth-profiles/{auth_profile_id}", get(get_auth_profile))
|
||||||
.route("/members", get(list_memberships))
|
.route("/members", get(list_memberships))
|
||||||
|
.route(
|
||||||
|
"/members/{user_id}",
|
||||||
|
axum::routing::patch(update_membership).delete(delete_membership),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/invitations",
|
"/invitations",
|
||||||
get(list_invitations).post(create_invitation),
|
get(list_invitations).post(create_invitation),
|
||||||
)
|
)
|
||||||
.route("/invitations/{invitation_id}", delete(delete_invitation))
|
.route("/invitations/{invitation_id}", delete(delete_invitation))
|
||||||
|
.route("/export", get(export_workspace))
|
||||||
.route(
|
.route(
|
||||||
"/platform-api-keys",
|
"/platform-api-keys",
|
||||||
get(list_platform_api_keys).post(create_platform_api_key),
|
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()
|
let workspace_scoped_router = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/workspaces/{workspace_id}",
|
"/workspaces/{workspace_id}",
|
||||||
get(get_workspace).patch(update_workspace),
|
get(get_workspace)
|
||||||
|
.patch(update_workspace)
|
||||||
|
.delete(delete_workspace),
|
||||||
)
|
)
|
||||||
.nest("/workspaces/{workspace_id}", workspace_router)
|
.nest("/workspaces/{workspace_id}", workspace_router)
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
@@ -765,6 +773,89 @@ mod tests {
|
|||||||
assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT);
|
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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn updates_profile_and_changes_password() {
|
async fn updates_profile_and_changes_password() {
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ impl From<RegistryError> for ApiError {
|
|||||||
RegistryError::UserNotFound { user_id } => {
|
RegistryError::UserNotFound { user_id } => {
|
||||||
Self::not_found(format!("user {user_id} was not found"))
|
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 } => {
|
RegistryError::AgentNotFound { agent_id } => {
|
||||||
Self::not_found(format!("agent {agent_id} was not found"))
|
Self::not_found(format!("agent {agent_id} was not found"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Extension, Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
@@ -7,8 +7,9 @@ use serde::Deserialize;
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
auth::AuthenticatedSession,
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
service::{InvitationPayload, PlatformApiKeyPayload},
|
service::{InvitationPayload, PlatformApiKeyPayload, UpdateMembershipPayload},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,6 +24,12 @@ pub struct WorkspaceInvitationPath {
|
|||||||
pub invitation_id: String,
|
pub invitation_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct WorkspaceMembershipPath {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct WorkspacePlatformApiKeyPath {
|
pub struct WorkspacePlatformApiKeyPath {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
@@ -40,6 +47,40 @@ pub async fn list_memberships(
|
|||||||
Ok(Json(json!({ "items": items })))
|
Ok(Json(json!({ "items": items })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_membership(
|
||||||
|
Path(path): Path<WorkspaceMembershipPath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
|
Json(payload): Json<UpdateMembershipPayload>,
|
||||||
|
) -> Result<Json<Value>, 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<WorkspaceMembershipPath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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(
|
pub async fn list_invitations(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -127,3 +168,26 @@ pub async fn delete_platform_api_key(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn export_workspace(
|
||||||
|
Path(path): Path<WorkspacePath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Value>, 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<WorkspacePath>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
state
|
||||||
|
.service
|
||||||
|
.delete_workspace(&path.workspace_id.as_str().into(), &session.user.id)
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|||||||
@@ -219,6 +219,11 @@ pub struct InvitationPayload {
|
|||||||
pub expires_at: Option<String>,
|
pub expires_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct UpdateMembershipPayload {
|
||||||
|
pub role: MembershipRole,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct CreatedInvitationResponse {
|
pub struct CreatedInvitationResponse {
|
||||||
pub invitation: InvitationRecord,
|
pub invitation: InvitationRecord,
|
||||||
@@ -237,6 +242,17 @@ pub struct CreatedPlatformApiKeyResponse {
|
|||||||
pub secret: String,
|
pub secret: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct WorkspaceExportResponse {
|
||||||
|
pub workspace: WorkspaceRecord,
|
||||||
|
pub memberships: Vec<MembershipRecord>,
|
||||||
|
pub invitations: Vec<Value>,
|
||||||
|
pub operations: Vec<OperationSummaryView>,
|
||||||
|
pub agents: Vec<AgentSummaryView>,
|
||||||
|
pub platform_api_keys: Vec<PlatformApiKeyRecord>,
|
||||||
|
pub exported_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct LogsQuery {
|
pub struct LogsQuery {
|
||||||
pub level: Option<InvocationLevel>,
|
pub level: Option<InvocationLevel>,
|
||||||
@@ -746,6 +762,107 @@ impl AdminService {
|
|||||||
Ok(self.registry.list_memberships(workspace_id).await?)
|
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<Vec<MembershipRecord>, 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))]
|
#[instrument(skip(self))]
|
||||||
pub async fn list_invitations(
|
pub async fn list_invitations(
|
||||||
&self,
|
&self,
|
||||||
@@ -804,6 +921,63 @@ impl AdminService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn export_workspace(
|
||||||
|
&self,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
) -> Result<WorkspaceExportResponse, ApiError> {
|
||||||
|
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))]
|
#[instrument(skip(self))]
|
||||||
pub async fn list_platform_api_keys(
|
pub async fn list_platform_api_keys(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -304,7 +304,7 @@
|
|||||||
<div class="danger-zone-action">
|
<div class="danger-zone-action">
|
||||||
<div class="danger-zone-text">
|
<div class="danger-zone-text">
|
||||||
<div class="danger-zone-title">Export all data</div>
|
<div class="danger-zone-title">Export all data</div>
|
||||||
<div class="danger-zone-desc">Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON.</div>
|
<div class="danger-zone-desc">Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-danger" id="export-workspace-btn" type="button">Export</button>
|
<button class="btn-danger" id="export-workspace-btn" type="button">Export</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -177,6 +177,12 @@
|
|||||||
listMemberships: function(workspaceId) {
|
listMemberships: function(workspaceId) {
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/members');
|
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) {
|
listInvitations: function(workspaceId) {
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations');
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations');
|
||||||
},
|
},
|
||||||
@@ -186,6 +192,12 @@
|
|||||||
deleteInvitation: function(workspaceId, invitationId) {
|
deleteInvitation: function(workspaceId, invitationId) {
|
||||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations/' + encodeURIComponent(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) {
|
listOperations: function(workspaceId) {
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations');
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations');
|
||||||
},
|
},
|
||||||
|
|||||||
+104
-13
@@ -7,6 +7,7 @@ var workspaceFormState = {
|
|||||||
workspaceRecord: null,
|
workspaceRecord: null,
|
||||||
memberships: [],
|
memberships: [],
|
||||||
invitations: [],
|
invitations: [],
|
||||||
|
currentUserId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeSlug(value) {
|
function normalizeSlug(value) {
|
||||||
@@ -101,16 +102,32 @@ function applyWorkspaceRecord(record) {
|
|||||||
function addMemberRow(container, membership) {
|
function addMemberRow(container, membership) {
|
||||||
var row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'member-row';
|
row.className = 'member-row';
|
||||||
|
var isCurrentUser = membership.user.id === workspaceFormState.currentUserId;
|
||||||
|
var canManage = membership.role !== 'owner';
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
'<div class="member-avatar" style="background:linear-gradient(135deg,' + workspaceFormState.selectedColor + ',#6366f1);">' +
|
'<div class="member-avatar" style="background:linear-gradient(135deg,' + workspaceFormState.selectedColor + ',#6366f1);">' +
|
||||||
membership.user.display_name.slice(0, 2).toUpperCase() +
|
membership.user.display_name.slice(0, 2).toUpperCase() +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="member-info">' +
|
'<div class="member-info">' +
|
||||||
'<div class="member-name">' + membership.user.display_name + '</div>' +
|
'<div class="member-name">' + membership.user.display_name +
|
||||||
|
(isCurrentUser ? ' <span style="font-size:11px;font-weight:400;color:var(--text-muted);">(you)</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
'<div class="member-email">' + membership.user.email + '</div>' +
|
'<div class="member-email">' + membership.user.email + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="member-last-active">—</div>' +
|
'<div class="member-last-active">—</div>' +
|
||||||
'<span class="member-role-badge ' + roleClass(membership.role) + '">' + roleLabel(membership.role) + '</span>';
|
(
|
||||||
|
canManage
|
||||||
|
? '<select class="member-role-select" data-previous="' + membership.role + '" onchange="updateRole(this, \'' + membership.user.id + '\')">' +
|
||||||
|
'<option value="owner"' + (membership.role === 'owner' ? ' selected' : '') + '>Owner</option>' +
|
||||||
|
'<option value="admin"' + (membership.role === 'admin' ? ' selected' : '') + '>Admin</option>' +
|
||||||
|
'<option value="operator"' + (membership.role === 'operator' ? ' selected' : '') + '>Developer</option>' +
|
||||||
|
'<option value="viewer"' + (membership.role === 'viewer' ? ' selected' : '') + '>Viewer</option>' +
|
||||||
|
'</select>' +
|
||||||
|
'<button class="member-remove-btn" type="button" onclick="removeMember(\'' + membership.user.id + '\', \'' + membership.user.display_name.replace(/'/g, "\\'") + '\')" title="Remove member">' +
|
||||||
|
'<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>' +
|
||||||
|
'</button>'
|
||||||
|
: '<span class="member-role-badge ' + roleClass(membership.role) + '">' + roleLabel(membership.role) + '</span>'
|
||||||
|
);
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +192,13 @@ async function loadWorkspaceAccessData() {
|
|||||||
return;
|
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 membershipsResponse = await window.CrankApi.listMemberships(workspaceFormState.workspaceId);
|
||||||
var invitationsResponse = await window.CrankApi.listInvitations(workspaceFormState.workspaceId);
|
var invitationsResponse = await window.CrankApi.listInvitations(workspaceFormState.workspaceId);
|
||||||
workspaceFormState.memberships = membershipsResponse && membershipsResponse.items ? membershipsResponse.items : [];
|
workspaceFormState.memberships = membershipsResponse && membershipsResponse.items ? membershipsResponse.items : [];
|
||||||
@@ -384,12 +408,42 @@ async function sendInvite() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRole() {
|
async function updateRole(select, userId) {
|
||||||
alert('Role management is not available yet in the backend.');
|
if (!workspaceFormState.workspaceId || !window.CrankApi) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMember() {
|
var previous = select.dataset.previous || select.value;
|
||||||
alert('Member removal is not available yet in the backend.');
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
async function revokeInviteById(invitationId) {
|
||||||
@@ -413,6 +467,48 @@ function revokeInvite(button) {
|
|||||||
row.remove();
|
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() {
|
async function initPage() {
|
||||||
updatePageMode();
|
updatePageMode();
|
||||||
|
|
||||||
@@ -421,13 +517,8 @@ async function initPage() {
|
|||||||
button.classList.toggle('active', button.dataset.lang === language);
|
button.classList.toggle('active', button.dataset.lang === language);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('export-workspace-btn').addEventListener('click', function () {
|
document.getElementById('export-workspace-btn').addEventListener('click', exportWorkspaceSnapshot);
|
||||||
alert('Workspace export is not available yet in the backend.');
|
document.getElementById('delete-workspace-btn').addEventListener('click', deleteWorkspaceAction);
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('delete-workspace-btn').addEventListener('click', function () {
|
|
||||||
alert('Workspace deletion is not available yet in the backend.');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (workspaceFormState.isCreateMode) {
|
if (workspaceFormState.isCreateMode) {
|
||||||
document.getElementById('ws-name').focus();
|
document.getElementById('ws-name').focus();
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ pub enum RegistryError {
|
|||||||
UserNotFound { user_id: String },
|
UserNotFound { user_id: String },
|
||||||
#[error("user with email {email} already exists")]
|
#[error("user with email {email} already exists")]
|
||||||
UserEmailAlreadyExists { email: String },
|
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")]
|
#[error("invitation {invitation_id} was not found")]
|
||||||
InvitationNotFound { invitation_id: String },
|
InvitationNotFound { invitation_id: String },
|
||||||
#[error("platform api key {key_id} was not found")]
|
#[error("platform api key {key_id} was not found")]
|
||||||
|
|||||||
@@ -415,6 +415,57 @@ impl PostgresRegistry {
|
|||||||
rows.iter().map(map_membership_record).collect()
|
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(
|
pub async fn list_invitations(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
@@ -493,6 +544,21 @@ impl PostgresRegistry {
|
|||||||
Ok(())
|
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(
|
pub async fn list_platform_api_keys(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
admin-api:
|
admin-api:
|
||||||
|
image: ${CRANK_ADMIN_API_IMAGE:-crank/admin-api:dev}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/admin-api/Dockerfile
|
dockerfile: apps/admin-api/Dockerfile
|
||||||
@@ -48,6 +49,7 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
mcp-server:
|
mcp-server:
|
||||||
|
image: ${CRANK_MCP_SERVER_IMAGE:-crank/mcp-server:dev}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/mcp-server/Dockerfile
|
dockerfile: apps/mcp-server/Dockerfile
|
||||||
@@ -74,6 +76,7 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
|
image: ${CRANK_UI_IMAGE:-crank/ui:dev}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/ui/Dockerfile
|
dockerfile: apps/ui/Dockerfile
|
||||||
|
|||||||
+9
-1
@@ -51,15 +51,23 @@
|
|||||||
- `POST /api/admin/workspaces`
|
- `POST /api/admin/workspaces`
|
||||||
- `GET /api/admin/workspaces/{workspace_id}`
|
- `GET /api/admin/workspaces/{workspace_id}`
|
||||||
- `PATCH /api/admin/workspaces/{workspace_id}`
|
- `PATCH /api/admin/workspaces/{workspace_id}`
|
||||||
|
- `DELETE /api/admin/workspaces/{workspace_id}`
|
||||||
- `GET /api/admin/workspaces/{workspace_id}/members`
|
- `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`
|
- `GET /api/admin/workspaces/{workspace_id}/invitations`
|
||||||
- `POST /api/admin/workspaces/{workspace_id}/invitations`
|
- `POST /api/admin/workspaces/{workspace_id}/invitations`
|
||||||
- `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}`
|
- `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}`
|
||||||
|
- `GET /api/admin/workspaces/{workspace_id}/export`
|
||||||
|
|
||||||
Контракт:
|
Контракт:
|
||||||
|
|
||||||
- `POST /invitations` возвращает metadata invitation и одноразовый `invite_token`;
|
- `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
|
### 5.2. Auth and session
|
||||||
|
|
||||||
|
|||||||
@@ -302,27 +302,31 @@ UI-файлы:
|
|||||||
- `GET /api/admin/workspaces/{workspace_id}`
|
- `GET /api/admin/workspaces/{workspace_id}`
|
||||||
- `PATCH /api/admin/workspaces/{workspace_id}`
|
- `PATCH /api/admin/workspaces/{workspace_id}`
|
||||||
- `GET /api/admin/workspaces/{workspace_id}/members`
|
- `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`
|
- `GET /api/admin/workspaces/{workspace_id}/invitations`
|
||||||
- `POST /api/admin/workspaces/{workspace_id}/invitations`
|
- `POST /api/admin/workspaces/{workspace_id}/invitations`
|
||||||
- `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}`
|
- `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;
|
- `switch current workspace` все еще живет на клиенте, а не в session/backend;
|
||||||
- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать;
|
- session-aware current workspace model;
|
||||||
- delete/export workspace lifecycle пока не реализован на backend
|
- finer-grained permission matrix beyond current `owner/admin` management rules.
|
||||||
|
|
||||||
Отдельный конфликт:
|
Отдельный конфликт:
|
||||||
|
|
||||||
- current workspace по-прежнему client-side;
|
- current workspace по-прежнему client-side;
|
||||||
- memberships и invitations уже live, но role-management пока read-only;
|
- memberships, role-management и invitations уже live;
|
||||||
- `settings` page не должна дублировать этот flow, пока у нее нет своего backend-контракта
|
- `settings` page не должна дублировать этот flow, пока у нее нет своего backend-контракта
|
||||||
|
|
||||||
Простой итог:
|
Простой итог:
|
||||||
|
|
||||||
- `workspace-setup` уже подключен к live backend;
|
- `workspace-setup` уже подключен к live backend;
|
||||||
- create/edit workspace, refresh списка workspace и invitations работают;
|
- create/edit workspace, memberships, invitations, export и delete работают;
|
||||||
- role management и workspace deletion остаются отдельным следующим этапом.
|
- текущий workspace все еще остается client-side моделью.
|
||||||
|
|
||||||
### 4.8. Settings
|
### 4.8. Settings
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -199,22 +199,37 @@ CI должен выполнять:
|
|||||||
CD для MVP должен:
|
CD для MVP должен:
|
||||||
|
|
||||||
- запускаться только после успешного `CI` на `main` или вручную;
|
- запускаться только после успешного `CI` на `main` или вручную;
|
||||||
- собирать production artifacts;
|
- собирать versioned container images;
|
||||||
|
- пушить их в container registry с cache;
|
||||||
- доставлять deployment files на целевой Linux host;
|
- доставлять deployment files на целевой Linux host;
|
||||||
- выполнять controlled restart;
|
- выполнять controlled restart через `pull`, без сборки на сервере;
|
||||||
- проверять health endpoints после запуска.
|
- проверять health endpoints после запуска.
|
||||||
|
|
||||||
Минимальный flow:
|
Минимальный flow:
|
||||||
|
|
||||||
1. push в `main`
|
1. push в `main`
|
||||||
2. успешный `CI`
|
2. успешный `CI`
|
||||||
3. build images или удаленная сборка на хосте
|
3. build and push images в registry
|
||||||
4. upload env и deployment files
|
4. upload env и deployment files
|
||||||
5. `docker compose config -q`
|
5. `docker login` на registry на целевом хосте
|
||||||
6. `docker compose up -d`
|
6. `docker compose config -q`
|
||||||
7. healthcheck verification для `ui`, `admin-api`, `mcp-server`
|
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. Что нельзя забыть
|
## 12. Что нельзя забыть
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ var/crank/
|
|||||||
Минимально ожидаются:
|
Минимально ожидаются:
|
||||||
|
|
||||||
- `CRANK_DATABASE_URL`
|
- `CRANK_DATABASE_URL`
|
||||||
|
- `CRANK_ADMIN_API_IMAGE`
|
||||||
|
- `CRANK_MCP_SERVER_IMAGE`
|
||||||
|
- `CRANK_UI_IMAGE`
|
||||||
- `CRANK_STORAGE_ROOT`
|
- `CRANK_STORAGE_ROOT`
|
||||||
- `CRANK_ADMIN_BIND`
|
- `CRANK_ADMIN_BIND`
|
||||||
- `CRANK_MCP_BIND`
|
- `CRANK_MCP_BIND`
|
||||||
@@ -116,6 +119,7 @@ Demo/deployment:
|
|||||||
- включенная app-level auth-защита admin-api;
|
- включенная app-level auth-защита admin-api;
|
||||||
- стабильный `Streamable HTTP` endpoint для MCP.
|
- стабильный `Streamable HTTP` endpoint для MCP.
|
||||||
- containerized runtime через `Docker` и `docker-compose`.
|
- containerized runtime через `Docker` и `docker-compose`.
|
||||||
|
- registry-backed image rollout через `GHCR` или совместимый registry.
|
||||||
|
|
||||||
### Auth env
|
### Auth env
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user