diff --git a/README.md b/README.md index 630445e..59dcda9 100644 --- a/README.md +++ b/README.md @@ -195,19 +195,17 @@ go test ./... # from sdks/images/go ## Deployment -The Vite output is configured for an assets-only Cloudflare Worker at -`cloud.knotree.com`: - -```powershell -pnpm --dir apps/web build -pnpm --dir apps/web exec wrangler deploy -``` - -Set `apps/web/.env.production` (or the CI build environment) to: - -```text -VITE_API_BASE_URL=https://cloudapi.knotree.com/api/v1 -``` +Production is deployed by GitHub CI on every push to `main`: the `deploy` job +rolls out the API and web images (by digest) to k3s, and `cloud.knotree.com` +reaches them through Cloudflare's proxied DNS record, nginx-edge and the Traefik +IngressRoute (API under `/api`, the dashboard everywhere else). See +`deploy/ci/README.md`. + +Do not attach a Cloudflare Worker custom domain to `cloud.knotree.com`. A +custom domain replaces the proxied DNS record, so the Worker answers every +request, including `/api/v1`, and the CI deploy stops being reachable. That +includes `apps/web/wrangler.jsonc` (`pnpm --dir apps/web exec wrangler deploy`) +and the Worker of the retired `knotree-cloud` repository. The API image is in `apps/api/Dockerfile`. The Helm chart in `deploy/helm/knotree-api` expects an existing secret containing `DATABASE_URL`. diff --git a/apps/api/src/app_services.rs b/apps/api/src/app_services.rs index 71ae23d..9a4a68c 100644 --- a/apps/api/src/app_services.rs +++ b/apps/api/src/app_services.rs @@ -49,9 +49,10 @@ use crate::{ models::{ AccountDeploymentLog, AppServiceDatabaseConnectionResponse, AppServiceDeploymentResponse, AppServiceLogsResponse, AppServiceMetricPoint, AppServiceMetricsResponse, - AppServiceResponse, CreateAppServiceRequest, UpdateAppServiceAutoDeployRequest, + AppServiceResponse, CreateAppServiceRequest, RegistryDeployHistoryResponse, + RegistryDeployJobResponse, UpdateAppServiceAutoDeployRequest, UpdateAppServiceDatabaseRequest, UpdateAppServicePublicAccessRequest, - UpdateAppServiceRequest, + UpdateAppServiceRegistryConnectionRequest, UpdateAppServiceRequest, }, html_pages::{self, IMAGE_SOURCE_HTML, IMAGE_SOURCE_HTML_GITHUB}, knotree_registry::{self, RegistryDockerCredentials}, @@ -1289,6 +1290,189 @@ pub async fn update_auto_deploy( )?)) } +#[derive(Debug, sqlx::FromRow)] +struct RegistryDeployJobRow { + id: Uuid, + image_digest: String, + status: String, + attempt_count: i32, + last_error: Option, + deployment_id: Option, + created_at: OffsetDateTime, + updated_at: OffsetDateTime, +} + +const REGISTRY_DEPLOY_HISTORY_LIMIT: i64 = 10; + +pub async fn registry_deploys( + State(state): State, + headers: HeaderMap, + Path((workspace_id, project_slug, app_service_id)): Path<(String, String, Uuid)>, +) -> Result, AppError> { + let user = auth::authenticate(&state, &headers).await?; + let project_id = + projects::accessible_project_id(&state, user.id, &workspace_id, &project_slug).await?; + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM project_app_services WHERE id = $1 AND project_id = $2)", + ) + .bind(app_service_id) + .bind(project_id) + .fetch_one(&state.db) + .await?; + if !exists { + return Err(AppError::NotFound { + code: "APP_SERVICE_NOT_FOUND", + message: "The app service could not be found.", + }); + } + let rows = sqlx::query_as::<_, RegistryDeployJobRow>( + "SELECT id, image_digest, status, attempt_count, last_error, deployment_id, + created_at, updated_at + FROM knotree_registry_deploy_jobs + WHERE app_service_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2", + ) + .bind(app_service_id) + .bind(REGISTRY_DEPLOY_HISTORY_LIMIT) + .fetch_all(&state.db) + .await?; + let jobs = rows + .into_iter() + .map(|row| { + Ok(RegistryDeployJobResponse { + id: row.id, + image_digest: row.image_digest, + status: row.status, + attempt_count: row.attempt_count, + last_error: row.last_error, + deployment_id: row.deployment_id, + received_at: row + .created_at + .format(&Rfc3339) + .map_err(AppError::internal)?, + updated_at: row + .updated_at + .format(&Rfc3339) + .map_err(AppError::internal)?, + }) + }) + .collect::, AppError>>()?; + Ok(Json(RegistryDeployHistoryResponse { + app_service_id, + auto_deploy_ready: state.config.knotree_registry_webhook_secret.is_some(), + jobs, + })) +} + +/// Attach a project Registry connection to an existing Registry service, so a +/// service whose connection was disconnected can pull and auto-deploy again +/// without being recreated. +pub async fn update_registry_connection( + State(state): State, + headers: HeaderMap, + Path((workspace_id, project_slug, app_service_id)): Path<(String, String, Uuid)>, + Json(input): Json, +) -> Result, AppError> { + security::require_csrf(&headers, &state.config)?; + let user = auth::authenticate(&state, &headers).await?; + let project_id = + projects::accessible_project_id(&state, user.id, &workspace_id, &project_slug).await?; + let existing = sqlx::query_as::<_, AppServiceRow>(&format!( + "SELECT {APP_SERVICE_COLUMNS} FROM project_app_services WHERE id = $1 AND project_id = $2" + )) + .bind(app_service_id) + .bind(project_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound { + code: "APP_SERVICE_NOT_FOUND", + message: "The app service could not be found.", + })?; + if existing.image_source != IMAGE_SOURCE_KNOTREE_REGISTRY { + return Err(AppError::BadRequest { + code: "REGISTRY_CONNECTION_SOURCE_UNSUPPORTED", + message: "Only Knotree Registry services can use a Registry connection.", + }); + } + let image = knotree_registry::parse_registry_image(&existing.image).ok_or_else(|| { + AppError::internal("the stored Knotree Registry image reference is invalid") + })?; + let repository = sqlx::query_scalar::<_, String>( + "SELECT repository FROM knotree_registry_connections + WHERE id = $1 AND project_id = $2 AND revoked_at IS NULL + AND (credential_expires_at IS NULL OR credential_expires_at > now())", + ) + .bind(input.connection_id) + .bind(project_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound { + code: "KNOTREE_REGISTRY_CONNECTION_NOT_FOUND", + message: "The Knotree Registry connection could not be found.", + })?; + if repository != image.repository { + return Err(AppError::BadRequest { + code: "KNOTREE_REGISTRY_REPOSITORY_MISMATCH", + message: "This connection grants pull access to a different repository.", + }); + } + + if state.config.uses_kubernetes_workloads() { + let credentials = + knotree_registry::load_credentials(&state, project_id, input.connection_id) + .await? + .ok_or(AppError::NotFound { + code: "KNOTREE_REGISTRY_CONNECTION_NOT_FOUND", + message: "The Knotree Registry connection could not be found.", + })?; + cluster_kubernetes::update_app_image_pull_secret( + &state.config, + app_service_id, + &credentials.username, + &credentials.password, + ) + .await + .map_err(|_| AppError::ServiceUnavailable { + code: "KNOTREE_REGISTRY_SECRET_UPDATE_FAILED", + message: "The connection is valid, but Kubernetes pull credentials could not be updated. Try again.", + })?; + } + + let service = sqlx::query_as::<_, AppServiceRow>(&format!( + "UPDATE project_app_services + SET registry_connection_id = $1, auto_deploy_error = NULL, updated_at = now() + WHERE id = $2 AND project_id = $3 AND image_source = $4 + AND EXISTS ( + SELECT 1 FROM knotree_registry_connections + WHERE id = $1 AND project_id = $3 AND revoked_at IS NULL + ) + RETURNING {APP_SERVICE_COLUMNS}" + )) + .bind(input.connection_id) + .bind(app_service_id) + .bind(project_id) + .bind(IMAGE_SOURCE_KNOTREE_REGISTRY) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::Conflict { + code: "KNOTREE_REGISTRY_CONNECTION_CHANGED", + message: "The Registry connection changed while it was being attached. Try again.", + })?; + + let database = + database_resource_by_id(&state, project_id, service.database_resource_id).await?; + Ok(Json(app_service_response( + &service, + &state.config.app_service_public_host, + state.config.bind_addr.port(), + state.config.app_service_public_domain.as_deref(), + &state.config.app_service_public_scheme, + database.as_ref(), + latest_deployment(&state.db, service.id).await?, + )?)) +} + pub async fn update_public_access( State(state): State, headers: HeaderMap, @@ -5457,4 +5641,130 @@ mod tests { .expect("assigned subdomain"); assert!(subdomain.is_some()); } + + #[tokio::test] + async fn registry_service_reconnects_and_lists_recent_registry_deploys() { + let Some(state) = crate::test_support::test_app_state_configured(|config| { + config.database_cluster_provider = "docker".to_owned(); + }) + .await + else { + return; + }; + let seed = crate::test_support::seed_owner_project(&state).await; + let headers = crate::test_support::session_headers(&state, seed.user_id).await; + let ciphertext = security::encrypt_secret( + "pull-token", + &state.config.database_credentials_encryption_key, + ) + .expect("encrypt token"); + let connection_id = Uuid::new_v4(); + let other_connection_id = Uuid::new_v4(); + for (id, repository) in [ + (connection_id, "team/api"), + (other_connection_id, "team/web"), + ] { + sqlx::query( + "INSERT INTO knotree_registry_connections + (id, project_id, user_id, registry_username, repository, credential_ciphertext) + VALUES ($1, $2, $3, 'deployer', $4, $5)", + ) + .bind(id) + .bind(seed.project_id) + .bind(seed.user_id) + .bind(repository) + .bind(&ciphertext) + .execute(&state.db) + .await + .expect("seed connection"); + } + let service_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO project_app_services + (id, project_id, name, image, image_source, app_port, status, auto_deploy_enabled, + auto_deploy_error) + VALUES ($1, $2, 'API', 'registry.knotree.com/team/api:production', + 'knotree_registry', 8080, 'ready', FALSE, + 'Knotree Registry connection was disconnected.')", + ) + .bind(service_id) + .bind(seed.project_id) + .execute(&state.db) + .await + .expect("seed disconnected registry service"); + let path = || { + Path(( + seed.workspace_route_id.clone(), + seed.project_slug.clone(), + service_id, + )) + }; + + let mismatch = update_registry_connection( + State(state.clone()), + headers.clone(), + path(), + Json(UpdateAppServiceRegistryConnectionRequest { + connection_id: other_connection_id, + }), + ) + .await + .err() + .expect("a connection for another repository is rejected"); + assert!(matches!( + mismatch, + AppError::BadRequest { + code: "KNOTREE_REGISTRY_REPOSITORY_MISMATCH", + .. + } + )); + + let Json(reconnected) = update_registry_connection( + State(state.clone()), + headers.clone(), + path(), + Json(UpdateAppServiceRegistryConnectionRequest { connection_id }), + ) + .await + .expect("matching connection is attached"); + assert_eq!(reconnected.registry_connection_id, Some(connection_id)); + assert_eq!(reconnected.auto_deploy_error, None); + + let digest = format!("sha256:{}", "a".repeat(64)); + let delivery_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO knotree_registry_webhook_deliveries (delivery_id, event_kind) + VALUES ($1, 'tag_updated')", + ) + .bind(delivery_id) + .execute(&state.db) + .await + .expect("seed delivery"); + sqlx::query( + "INSERT INTO knotree_registry_deploy_jobs + (id, delivery_id, app_service_id, image_digest, immutable_image, status, last_error) + VALUES ($1, $2, $3, $4, $5, 'failed', 'Registry image deployment failed.')", + ) + .bind(Uuid::new_v4()) + .bind(delivery_id) + .bind(service_id) + .bind(&digest) + .bind(format!("registry.knotree.com/team/api@{digest}")) + .execute(&state.db) + .await + .expect("seed deploy job"); + + let Json(history) = registry_deploys(State(state.clone()), headers.clone(), path()) + .await + .expect("history is readable by a project member"); + assert_eq!(history.app_service_id, service_id); + assert!(!history.auto_deploy_ready); + assert_eq!(history.jobs.len(), 1); + assert_eq!(history.jobs[0].image_digest, digest); + assert_eq!(history.jobs[0].status, "failed"); + assert_eq!( + history.jobs[0].last_error.as_deref(), + Some("Registry image deployment failed.") + ); + } } diff --git a/apps/api/src/lib.rs b/apps/api/src/lib.rs index f99e218..baf7692 100644 --- a/apps/api/src/lib.rs +++ b/apps/api/src/lib.rs @@ -120,6 +120,14 @@ pub fn router(state: AppState) -> Router { "/workspaces/{workspace_id}/projects/{project_slug}/app-services/{app_service_id}/auto-deploy", patch(app_services::update_auto_deploy), ) + .route( + "/workspaces/{workspace_id}/projects/{project_slug}/app-services/{app_service_id}/registry-connection", + patch(app_services::update_registry_connection), + ) + .route( + "/workspaces/{workspace_id}/projects/{project_slug}/app-services/{app_service_id}/registry-deploys", + get(app_services::registry_deploys), + ) .route( "/workspaces/{workspace_id}/projects/{project_slug}/app-services/{app_service_id}/database", patch(app_services::update_database_connection), diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 9d56210..692f49c 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -216,6 +216,33 @@ pub struct UpdateAppServiceAutoDeployRequest { pub enabled: bool, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAppServiceRegistryConnectionRequest { + pub connection_id: Uuid, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegistryDeployJobResponse { + pub id: Uuid, + pub image_digest: String, + pub status: String, + pub attempt_count: i32, + pub last_error: Option, + pub deployment_id: Option, + pub received_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegistryDeployHistoryResponse { + pub app_service_id: Uuid, + pub auto_deploy_ready: bool, + pub jobs: Vec, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateAppServiceDatabaseRequest { diff --git a/apps/web/src/components/app-service-create-dialog.tsx b/apps/web/src/components/app-service-create-dialog.tsx index c548088..281986d 100644 --- a/apps/web/src/components/app-service-create-dialog.tsx +++ b/apps/web/src/components/app-service-create-dialog.tsx @@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Spinner } from "@/components/ui/spinner" import { ApiError } from "@/lib/api" +import { registryAuthorizationTarget } from "@/lib/registry-consent" import { type CreateAppServiceInput, createAppService, @@ -657,25 +658,11 @@ export function AppServiceCreateDialog({ projectSlug, registryRepository ) - const target = new URL( - result.authorizationUrl - ) - if ( - target.origin !== - "https://registry.knotree.com" || - !/^\/cloud\/authorize\/[0-9a-f-]{36}$/.test( - target.pathname - ) || - target.search || - target.hash || - target.username || - target.password - ) { - throw new Error( - "Registry returned an invalid authorization URL." + window.location.assign( + registryAuthorizationTarget( + result.authorizationUrl ) - } - window.location.assign(target.href) + ) } catch (reason) { setSubmitError( reason instanceof Error diff --git a/apps/web/src/components/registry-connection-panel.test.tsx b/apps/web/src/components/registry-connection-panel.test.tsx new file mode 100644 index 0000000..9a4a65e --- /dev/null +++ b/apps/web/src/components/registry-connection-panel.test.tsx @@ -0,0 +1,258 @@ +import { render, screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { RegistryConnectionPanel } from "@/components/registry-connection-panel" +import type { AppService } from "@/lib/types" + +const mocks = vi.hoisted(() => ({ + attachAppServiceRegistryConnection: vi.fn(), + createKnotreeRegistryConnection: vi.fn(), + deleteKnotreeRegistryConnection: vi.fn(), + listAppServices: vi.fn(), + listKnotreeRegistryConnections: vi.fn(), + listRegistryDeploys: vi.fn(), + startKnotreeRegistryConsent: vi.fn(), + updateAppServiceAutoDeploy: vi.fn(), + updateKnotreeRegistryConnection: vi.fn(), +})) + +vi.mock("@/lib/resources", () => mocks) + +const WORKSPACE = "de305d54-75b4-431b-adb2-eb6b9e546014" +const DIGEST = `sha256:${"a".repeat(64)}` + +const service: AppService = { + id: "app-1", + name: "API", + resourceType: "app", + status: "ready", + image: "registry.knotree.com/team/api:production", + imageSource: "knotree_registry", + appPort: 8080, + host: "localhost", + port: 59601, + serviceUrl: null, + containerName: "knotree-app-1", + autoDeployEnabled: true, + deployedImageDigest: DIGEST, + autoDeployCheckedAt: "2026-10-01T09:00:00Z", + registryConnectionId: "connection-1", +} + +function renderPanel( + appService: AppService, + handlers: { + onToast?: (message: string) => void + onAppServiceUpdated?: (resource: AppService) => void + } = {} +) { + return render( + + ) +} + +describe("RegistryConnectionPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listRegistryDeploys.mockResolvedValue({ + appServiceId: "app-1", + autoDeployReady: true, + jobs: [], + }) + mocks.listKnotreeRegistryConnections.mockResolvedValue({ + connections: [], + autoDeployReady: true, + }) + }) + + it("shows a live connection with the push-to-deploy flow and recent pushes", async () => { + mocks.listRegistryDeploys.mockResolvedValue({ + appServiceId: "app-1", + autoDeployReady: true, + jobs: [ + { + id: "job-3", + imageDigest: `sha256:${"c".repeat(64)}`, + status: "failed", + attemptCount: 1, + lastError: "Registry image deployment failed.", + deploymentId: "deployment-3", + receivedAt: "2026-10-01T09:30:00Z", + updatedAt: "2026-10-01T09:31:00Z", + }, + { + id: "job-2", + imageDigest: `sha256:${"b".repeat(64)}`, + status: "succeeded", + attemptCount: 0, + lastError: null, + deploymentId: null, + receivedAt: "2026-10-01T09:20:00Z", + updatedAt: "2026-10-01T09:20:00Z", + }, + { + id: "job-1", + imageDigest: DIGEST, + status: "succeeded", + attemptCount: 1, + lastError: null, + deploymentId: "deployment-1", + receivedAt: "2026-10-01T09:00:00Z", + updatedAt: "2026-10-01T09:02:00Z", + }, + ], + }) + + renderPanel(service) + + const panel = screen.getByRole("region", { + name: "Knotree Registry connection", + }) + expect(within(panel).getByText("Live")).toBeInTheDocument() + expect( + within(panel).getByText( + "Every push to the production tag deploys its exact image digest automatically." + ) + ).toBeInTheDocument() + expect( + within(panel).getByRole("list", { name: "How auto deploy works" }) + ).toHaveTextContent("You push a new image to the production tag.") + expect(mocks.listRegistryDeploys).toHaveBeenCalledWith( + WORKSPACE, + "demo", + "app-1" + ) + expect(await within(panel).findByText("Failed")).toBeInTheDocument() + expect(within(panel).getByText("Skipped")).toBeInTheDocument() + expect(within(panel).getByText("Deployed")).toBeInTheDocument() + expect( + within(panel).getByText("Registry image deployment failed.") + ).toBeInTheDocument() + expect( + within(panel).getByText(`sha256:${"c".repeat(12)}`) + ).toBeInTheDocument() + expect( + within(panel).queryByRole("heading", { name: "Reconnect" }) + ).not.toBeInTheDocument() + }) + + it("marks auto deploy unavailable when Cloud has no webhook secret", async () => { + mocks.listRegistryDeploys.mockResolvedValue({ + appServiceId: "app-1", + autoDeployReady: false, + jobs: [], + }) + renderPanel({ ...service, autoDeployEnabled: false }) + + expect(await screen.findByText("Unavailable")).toBeInTheDocument() + expect( + screen.getByRole("checkbox", { + name: "Auto deploy new Knotree Registry images", + }) + ).toBeDisabled() + expect(screen.getByText(/No pushes received yet/)).toBeInTheDocument() + }) + + it("reconnects a disconnected service to a saved connection without recreating it", async () => { + const user = userEvent.setup() + const onToast = vi.fn() + const onAppServiceUpdated = vi.fn() + const disconnected = { + ...service, + autoDeployEnabled: false, + registryConnectionId: null, + } + mocks.listKnotreeRegistryConnections.mockResolvedValue({ + connections: [ + { + id: "other-repo", + registryHost: "registry.knotree.com", + username: "deployer", + repository: "team/web", + verifiedAt: "2026-10-01T08:00:00Z", + }, + { + id: "connection-2", + registryHost: "registry.knotree.com", + username: "deployer", + repository: "team/api", + verifiedAt: "2026-10-01T08:00:00Z", + }, + ], + autoDeployReady: true, + }) + mocks.attachAppServiceRegistryConnection.mockResolvedValue({ + ...disconnected, + registryConnectionId: "connection-2", + }) + + renderPanel(disconnected, { onToast, onAppServiceUpdated }) + + expect(screen.getByText("Disconnected")).toBeInTheDocument() + expect( + screen.queryByRole("checkbox", { + name: "Auto deploy new Knotree Registry images", + }) + ).not.toBeInTheDocument() + const choice = await screen.findByLabelText("Saved connection") + expect(within(choice).getAllByRole("option")).toHaveLength(1) + await user.click(screen.getByRole("button", { name: "Reconnect" })) + + await waitFor(() => { + expect(mocks.attachAppServiceRegistryConnection).toHaveBeenCalledWith( + WORKSPACE, + "demo", + "app-1", + "connection-2" + ) + }) + expect(onAppServiceUpdated).toHaveBeenCalledWith( + expect.objectContaining({ registryConnectionId: "connection-2" }) + ) + expect(onToast).toHaveBeenCalledWith( + "Knotree Registry reconnected. Turn on auto deploy to deploy new pushes automatically." + ) + }) + + it("verifies a new pull token for the service repository and attaches it", async () => { + const user = userEvent.setup() + mocks.createKnotreeRegistryConnection.mockResolvedValue({ + id: "connection-3", + registryHost: "registry.knotree.com", + username: "deployer", + repository: "team/api", + verifiedAt: "2026-10-01T08:00:00Z", + }) + mocks.attachAppServiceRegistryConnection.mockResolvedValue({ + ...service, + registryConnectionId: "connection-3", + }) + + renderPanel({ ...service, registryConnectionId: null }) + + await user.type(screen.getByLabelText("Registry username"), "deployer") + await user.type(screen.getByLabelText("Pull-only token"), "pull-pat") + await user.click(screen.getByRole("button", { name: "Connect with token" })) + + await waitFor(() => { + expect(mocks.createKnotreeRegistryConnection).toHaveBeenCalledWith( + WORKSPACE, + "demo", + { username: "deployer", token: "pull-pat", repository: "team/api" } + ) + expect(mocks.attachAppServiceRegistryConnection).toHaveBeenCalledWith( + WORKSPACE, + "demo", + "app-1", + "connection-3" + ) + }) + }) +}) diff --git a/apps/web/src/components/registry-connection-panel.tsx b/apps/web/src/components/registry-connection-panel.tsx new file mode 100644 index 0000000..0950d49 --- /dev/null +++ b/apps/web/src/components/registry-connection-panel.tsx @@ -0,0 +1,695 @@ +import { useEffect, useState, type FormEvent } from "react" +import { + BoxesIcon, + RefreshCwIcon, + RocketIcon, + ShieldCheckIcon, + UploadIcon, +} from "lucide-react" +import { cn } from "cn" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Spinner } from "@/components/ui/spinner" +import { ApiError } from "@/lib/api" +import { registryAuthorizationTarget } from "@/lib/registry-consent" +import { + attachAppServiceRegistryConnection, + createKnotreeRegistryConnection, + deleteKnotreeRegistryConnection, + listAppServices, + listKnotreeRegistryConnections, + listRegistryDeploys, + startKnotreeRegistryConsent, + updateAppServiceAutoDeploy, + updateKnotreeRegistryConnection, +} from "@/lib/resources" +import type { + AppService, + KnotreeRegistryConnectionList, + RegistryDeployHistory, + RegistryDeployJob, +} from "@/lib/types" + +const REGISTRY_HOST = "registry.knotree.com" + +type Tone = "ok" | "warn" | "danger" | "muted" + +function parseRegistryImage(image: string) { + const withoutHost = image.startsWith(`${REGISTRY_HOST}/`) + ? image.slice(REGISTRY_HOST.length + 1) + : image + const separator = withoutHost.lastIndexOf(":") + if (separator <= 0) return { repository: withoutHost, tag: "latest" } + return { + repository: withoutHost.slice(0, separator), + tag: withoutHost.slice(separator + 1), + } +} + +function shortDigest(digest: string) { + return /^sha256:[0-9a-f]{64}$/.test(digest) ? digest.slice(0, 19) : digest +} + +function relativeTime(value: string, now = Date.now()) { + const seconds = Math.round((new Date(value).getTime() - now) / 1000) + const format = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) + const abs = Math.abs(seconds) + if (abs < 60) return format.format(seconds, "second") + if (abs < 3600) return format.format(Math.round(seconds / 60), "minute") + if (abs < 86_400) return format.format(Math.round(seconds / 3600), "hour") + return format.format(Math.round(seconds / 86_400), "day") +} + +function jobLabel(job: RegistryDeployJob): { label: string; tone: Tone } { + switch (job.status) { + case "pending": + return { label: "Queued", tone: "muted" } + case "running": + return { label: "Deploying", tone: "warn" } + case "failed": + return { label: "Failed", tone: "danger" } + default: + // A succeeded job without a deployment was a no-op: the digest was + // already running, or a newer push for the same tag replaced it. + return job.deploymentId + ? { label: "Deployed", tone: "ok" } + : { label: "Skipped", tone: "muted" } + } +} + +function connectionState( + connected: boolean, + enabled: boolean, + autoDeployReady: boolean | null, + tag: string +): { label: string; tone: Tone; text: string } { + if (!connected) { + return { + label: "Disconnected", + tone: "danger", + text: "Cloud cannot pull new images for this service. The current version keeps running. Reconnect to resume pulls and automatic deploys.", + } + } + if (autoDeployReady === false) { + return { + label: "Unavailable", + tone: "muted", + text: "Automatic Registry deploys are not configured on this Cloud installation yet. Manual deploys from the connected repository still work.", + } + } + if (enabled) { + return { + label: "Live", + tone: "ok", + text: `Every push to the ${tag} tag deploys its exact image digest automatically.`, + } + } + return { + label: "Paused", + tone: "warn", + text: `Pushes to the ${tag} tag are received but not deployed. Turn on auto deploy to resume.`, + } +} + +export function RegistryConnectionPanel({ + appService, + workspaceId, + projectSlug, + onToast, + onAppServiceUpdated, +}: { + appService: AppService + workspaceId: string + projectSlug: string + onToast: (message: string) => void + onAppServiceUpdated?: (resource: AppService) => void +}) { + const { repository, tag } = parseRegistryImage(appService.image) + const connected = Boolean(appService.registryConnectionId) + const [enabled, setEnabled] = useState(appService.autoDeployEnabled ?? false) + const [history, setHistory] = useState(null) + const [historyError, setHistoryError] = useState(null) + const [historyVersion, setHistoryVersion] = useState(0) + const [isSaving, setIsSaving] = useState(false) + const [isDisconnecting, setIsDisconnecting] = useState(false) + const [isRotatingToken, setIsRotatingToken] = useState(false) + const [replacementToken, setReplacementToken] = useState("") + const [error, setError] = useState(null) + const isBusy = isSaving || appService.status === "provisioning" + + useEffect(() => { + let cancelled = false + listRegistryDeploys(workspaceId, projectSlug, appService.id) + .then((result) => { + if (cancelled) return + setHistory(result) + setHistoryError(null) + }) + .catch((caught: unknown) => { + if (cancelled) return + setHistoryError( + caught instanceof ApiError + ? caught.message + : "Recent Registry pushes could not be loaded." + ) + }) + return () => { + cancelled = true + } + }, [ + workspaceId, + projectSlug, + appService.id, + appService.deployedImageDigest, + appService.autoDeployCheckedAt, + historyVersion, + ]) + + const autoDeployReady = history?.autoDeployReady ?? null + const state = connectionState(connected, enabled, autoDeployReady, tag) + + async function handleToggle(nextEnabled: boolean) { + setEnabled(nextEnabled) + setIsSaving(true) + setError(null) + try { + const resource = await updateAppServiceAutoDeploy( + workspaceId, + projectSlug, + appService.id, + { enabled: nextEnabled } + ) + setEnabled(resource.autoDeployEnabled ?? nextEnabled) + onAppServiceUpdated?.(resource) + onToast( + nextEnabled + ? "Automatic Knotree Registry deploys enabled." + : "Automatic Knotree Registry deploys disabled." + ) + } catch (caught) { + setEnabled(appService.autoDeployEnabled ?? false) + setError( + caught instanceof ApiError + ? caught.message + : "Automatic image deploys could not be updated." + ) + } finally { + setIsSaving(false) + } + } + + async function disconnect() { + const connectionId = appService.registryConnectionId + if (!connectionId) return + setIsDisconnecting(true) + setError(null) + try { + await deleteKnotreeRegistryConnection( + workspaceId, + projectSlug, + connectionId + ) + const services = await listAppServices(workspaceId, projectSlug) + const updated = services.find((service) => service.id === appService.id) + if (updated) onAppServiceUpdated?.(updated) + onToast( + "Cloud connection removed. Revoke the PAT in Knotree Registry too if you no longer need it. The running service stays up; future Cloud pulls and auto-deploys are stopped." + ) + } catch (caught) { + setError( + caught instanceof ApiError + ? caught.message + : "The Knotree Registry connection could not be disconnected." + ) + } finally { + setIsDisconnecting(false) + } + } + + async function rotateToken(event: FormEvent) { + event.preventDefault() + const connectionId = appService.registryConnectionId + if (!connectionId || !replacementToken.trim()) return + setIsRotatingToken(true) + setError(null) + try { + await updateKnotreeRegistryConnection( + workspaceId, + projectSlug, + connectionId, + replacementToken.trim() + ) + setReplacementToken("") + onToast("Knotree Registry token updated for this project connection.") + } catch (caught) { + setError( + caught instanceof ApiError + ? caught.message + : "The Knotree Registry token could not be updated." + ) + } finally { + setIsRotatingToken(false) + } + } + + return ( +
+
+ +
+ Knotree Registry + + {REGISTRY_HOST}/{repository}:{tag} + +
+ + {state.label} + +
+

{state.text}

+ +
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
+ + {connected ? ( +
+
+ Auto deploy + Deploy each new digest pushed to {tag}. +
+ +
+ ) : null} + +
+
+
Repository
+
+ {repository} +
+
+
+
Watched tag
+
+ {tag} +
+
+
+
Deployed digest
+
+ + {appService.deployedImageDigest + ? shortDigest(appService.deployedImageDigest) + : "Not recorded yet"} + +
+
+
+
Last Registry event
+
+ {appService.autoDeployCheckedAt + ? new Date(appService.autoDeployCheckedAt).toLocaleString() + : "None yet"} +
+
+
+ +
+
+

Recent pushes

+ +
+ {historyError ? ( +

+ {historyError} +

+ ) : !history ? ( +

+ Loading recent pushes… +

+ ) : history.jobs.length === 0 ? ( +

+ No pushes received yet. Push a new image to {tag} and + it appears here. +

+ ) : ( +
    + {history.jobs.map((job) => { + const { label, tone } = jobLabel(job) + return ( +
  • + + {label} + + + {shortDigest(job.imageDigest)} + + + {job.status === "failed" && job.lastError ? ( +

    {job.lastError}

    + ) : null} +
  • + ) + })} +
+ )} +
+ + {connected ? ( +
+
+ +
+ setReplacementToken(event.target.value)} + placeholder="Paste a new pull-only token" + /> + +
+ + The replacement is verified for this repository and remains + encrypted. Disconnecting removes Cloud's saved connection; + revoke the PAT in Knotree Registry separately if needed. + +
+ +
+ ) : ( + + )} + + {appService.autoDeployError && connected ? ( +

+ {appService.autoDeployError} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} + +function RegistryReconnect({ + appService, + repository, + workspaceId, + projectSlug, + onToast, + onAppServiceUpdated, +}: { + appService: AppService + repository: string + workspaceId: string + projectSlug: string + onToast: (message: string) => void + onAppServiceUpdated?: (resource: AppService) => void +}) { + const [connections, setConnections] = + useState(null) + const [selected, setSelected] = useState("") + const [username, setUsername] = useState("") + const [token, setToken] = useState("") + const [busy, setBusy] = useState<"attach" | "create" | "consent" | null>(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + listKnotreeRegistryConnections(workspaceId, projectSlug) + .then((result) => { + if (!cancelled) setConnections(result) + }) + .catch(() => { + if (!cancelled) { + setConnections({ connections: [], autoDeployReady: false }) + } + }) + return () => { + cancelled = true + } + }, [workspaceId, projectSlug]) + + const matching = + connections?.connections.filter( + (connection) => connection.repository === repository + ) ?? [] + const selectedId = selected || matching[0]?.id || "" + + async function attach(connectionId: string) { + const resource = await attachAppServiceRegistryConnection( + workspaceId, + projectSlug, + appService.id, + connectionId + ) + onAppServiceUpdated?.(resource) + onToast( + "Knotree Registry reconnected. Turn on auto deploy to deploy new pushes automatically." + ) + } + + async function attachSaved() { + if (!selectedId) return + setBusy("attach") + setError(null) + try { + await attach(selectedId) + } catch (caught) { + setError( + caught instanceof ApiError + ? caught.message + : "The Registry connection could not be attached." + ) + } finally { + setBusy(null) + } + } + + async function connectWithToken(event: FormEvent) { + event.preventDefault() + if (!username.trim() || !token.trim()) return + setBusy("create") + setError(null) + try { + const connection = await createKnotreeRegistryConnection( + workspaceId, + projectSlug, + { username: username.trim(), token: token.trim(), repository } + ) + setToken("") + await attach(connection.id) + } catch (caught) { + setError( + caught instanceof ApiError + ? caught.message + : "Knotree Registry could not verify this token." + ) + } finally { + setBusy(null) + } + } + + async function authorizeOnRegistry() { + setBusy("consent") + setError(null) + try { + const result = await startKnotreeRegistryConsent( + workspaceId, + projectSlug, + repository + ) + window.location.assign( + registryAuthorizationTarget(result.authorizationUrl) + ) + } catch (caught) { + setError( + caught instanceof Error + ? caught.message + : "Registry authorization failed." + ) + setBusy(null) + } + } + + return ( +
+

Reconnect

+

+ Cloud needs pull access to repository:{repository}:pull. + The service is not recreated and keeps its URL, variables and database. +

+ {connections === null ? ( +

+ Loading saved connections… +

+ ) : null} + {matching.length ? ( +
+ +
+ + +
+
+ ) : null} + {connections?.consentReady ? ( +
+ + + Approve pull access on Registry, then come back and pick the saved + connection above. + +
+ ) : null} +
+
+ + setUsername(event.target.value)} + /> +
+
+ + setToken(event.target.value)} + /> +
+ +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} diff --git a/apps/web/src/components/resource-workspace.css b/apps/web/src/components/resource-workspace.css index 7a35b02..3fafd7d 100644 --- a/apps/web/src/components/resource-workspace.css +++ b/apps/web/src/components/resource-workspace.css @@ -2099,15 +2099,295 @@ font-size: 12px; } -.resource-workspace-registry-connection-actions { +.registry-panel { + display: grid; + gap: 16px; +} + +.registry-panel code { + font-size: 12px; + overflow-wrap: anywhere; +} + +.registry-panel-head { + display: flex; + align-items: center; + gap: 12px; +} + +.registry-panel-icon { + display: inline-grid; + width: 36px; + height: 36px; + flex: 0 0 auto; + place-items: center; + border-radius: 10px; + background: var(--project-accent-soft); + color: var(--project-accent); +} + +.registry-panel-icon svg { + width: 18px; + height: 18px; +} + +.registry-panel-title { + display: grid; + min-width: 0; + flex: 1 1 auto; + gap: 2px; +} + +.registry-panel-title code { + color: var(--project-muted); +} + +.registry-panel-pill, +.registry-panel-chip { + display: inline-flex; + min-height: 24px; + flex: 0 0 auto; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 1px solid var(--project-border); + border-radius: 999px; + color: var(--project-muted); + font-size: 12px; + font-weight: 650; +} + +.registry-panel-pill::before { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + content: ""; +} + +.registry-panel-pill.ok, +.registry-panel-chip.ok { + border-color: color-mix( + in oklch, + var(--project-ok) 35%, + var(--project-border) + ); + background: var(--project-ok-soft); + color: var(--project-ok); +} + +.registry-panel-pill.warn, +.registry-panel-chip.warn { + border-color: color-mix( + in oklch, + var(--project-warn) 35%, + var(--project-border) + ); + background: color-mix(in oklch, var(--project-warn) 10%, transparent); + color: var(--project-warn); +} + +.registry-panel-pill.danger, +.registry-panel-chip.danger { + border-color: color-mix( + in oklch, + var(--project-danger) 35%, + var(--project-border) + ); + background: color-mix(in oklch, var(--project-danger) 10%, transparent); + color: var(--project-danger); +} + +.registry-panel-summary { + margin: 0; + color: var(--project-muted); + font-size: 13px; +} + +.registry-panel-flow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.registry-panel-flow li { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 10px; + padding: 12px; + border: 1px solid var(--project-border); + border-radius: 10px; + background: var(--project-surface); +} + +.registry-panel-flow svg { + width: 16px; + height: 16px; + flex: 0 0 auto; + margin-top: 2px; + color: var(--project-accent); +} + +.registry-panel-flow div { + display: grid; + min-width: 0; + gap: 2px; +} + +.registry-panel-flow strong { + font-size: 13px; +} + +.registry-panel-flow span { + color: var(--project-muted); + font-size: 12px; +} + +.registry-panel-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border: 1px solid var(--project-border); + border-radius: 10px; +} + +.registry-panel-toggle > div { + display: grid; + gap: 2px; +} + +.registry-panel-toggle > div span { + color: var(--project-muted); + font-size: 12px; +} + +.registry-panel-toggle label { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--project-muted); + font-size: 13px; + white-space: nowrap; +} + +.registry-panel-toggle input { + accent-color: var(--project-accent); +} + +.registry-panel-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 16px; + margin: 0; +} + +.registry-panel-facts dt { + color: var(--project-muted); + font-size: 12px; +} + +.registry-panel-facts dd { + min-width: 0; + margin: 2px 0 0; + font-size: 13px; +} + +.registry-panel-history, +.registry-panel-reconnect { + display: grid; + gap: 8px; + padding-top: 16px; + border-top: 1px solid var(--project-border); +} + +.registry-panel h4 { + margin: 0; + font-size: 13px; + font-weight: 650; +} + +.registry-panel-history-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.registry-panel-jobs { + display: grid; + margin: 0; + padding: 0; + list-style: none; +} + +.registry-panel-jobs li { + display: grid; + grid-template-columns: 96px minmax(0, 1fr) auto; + align-items: center; + gap: 4px 12px; + padding: 8px 0; + border-bottom: 1px solid var(--project-border); +} + +.registry-panel-jobs li:last-child { + border-bottom: 0; +} + +.registry-panel-jobs .registry-panel-chip { + justify-self: start; +} + +.registry-panel-jobs time { + color: var(--project-muted); + font-size: 12px; +} + +.registry-panel-jobs p { + grid-column: 2 / -1; + margin: 0; + color: var(--project-danger); + font-size: 12px; +} + +.registry-panel-muted { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + color: var(--project-muted); + font-size: 13px; +} + +.registry-panel-empty, +.registry-panel-reconnect .registry-panel-muted { + display: block; + margin: 0; + color: var(--project-muted); + font-size: 13px; +} + +.registry-panel-error { + margin: 0; + color: var(--project-danger); + font-size: 13px; +} + +.registry-panel-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; - margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--project-border); } -.resource-workspace-registry-connection-actions form { +.registry-panel-actions form, +.registry-panel-reconnect-row { display: grid; flex: 1 1 360px; gap: 6px; @@ -2115,21 +2395,68 @@ font-size: 13px; } -.resource-workspace-registry-connection-actions form > div { +.registry-panel-actions form > div, +.registry-panel-reconnect-row > div { display: flex; gap: 8px; } -.resource-workspace-registry-connection-actions small { +.registry-panel-reconnect-row select { + min-height: 32px; + flex: 1 1 auto; + padding: 0 10px; + border: 1px solid var(--project-border); + border-radius: 8px; + background: var(--project-bg); + color: var(--project-fg); + font: inherit; +} + +.registry-panel-reconnect-row > button { + justify-self: start; +} + +.registry-panel-actions small, +.registry-panel-reconnect-row small { color: var(--project-muted); font-size: 12px; } -@media (max-width: 560px) { - .resource-workspace-registry-connection-actions form > div { +.registry-panel-reconnect-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + align-items: end; + gap: 8px; +} + +.registry-panel-reconnect-form > div { + display: grid; + gap: 4px; + color: var(--project-muted); + font-size: 12px; +} + +@media (max-width: 640px) { + .registry-panel-flow, + .registry-panel-facts, + .registry-panel-reconnect-form { + grid-template-columns: minmax(0, 1fr); + } + + .registry-panel-actions form > div, + .registry-panel-reconnect-row > div { align-items: stretch; flex-direction: column; } + + .registry-panel-jobs li { + grid-template-columns: minmax(0, 1fr) auto; + } + + .registry-panel-jobs code { + grid-column: 1 / -1; + grid-row: 2; + } } .resource-workspace-attachment-field { diff --git a/apps/web/src/components/resource-workspace.test.tsx b/apps/web/src/components/resource-workspace.test.tsx index e16b50f..3c70cec 100644 --- a/apps/web/src/components/resource-workspace.test.tsx +++ b/apps/web/src/components/resource-workspace.test.tsx @@ -32,6 +32,11 @@ const mocks = vi.hoisted(() => ({ updateAppServiceDatabase: vi.fn(), updateAppServicePublicAccess: vi.fn(), updateKnotreeRegistryConnection: vi.fn(), + attachAppServiceRegistryConnection: vi.fn(), + createKnotreeRegistryConnection: vi.fn(), + listKnotreeRegistryConnections: vi.fn(), + listRegistryDeploys: vi.fn(), + startKnotreeRegistryConsent: vi.fn(), })) vi.mock("@/lib/resources", () => mocks) @@ -730,6 +735,15 @@ describe("ResourceWorkspace settings pane", () => { }) mocks.deleteKnotreeRegistryConnection.mockResolvedValue(undefined) mocks.listAppServices.mockResolvedValue([disconnectedService]) + mocks.listRegistryDeploys.mockResolvedValue({ + appServiceId: "app-resource-id", + autoDeployReady: true, + jobs: [], + }) + mocks.listKnotreeRegistryConnections.mockResolvedValue({ + connections: [], + autoDeployReady: true, + }) render( { const dialog = screen.getByRole("dialog") await user.click(within(dialog).getByRole("tab", { name: "Settings" })) - expect(within(dialog).getByText("sha256:0123456789abcdef")).toBeInTheDocument() + expect( + within(dialog).getByText("sha256:0123456789abcdef") + ).toBeInTheDocument() const toggle = within(dialog).getByRole("checkbox", { name: "Auto deploy new Knotree Registry images", }) @@ -776,7 +792,9 @@ describe("ResourceWorkspace settings pane", () => { within(dialog).getByLabelText("Replace pull token"), "replacement-pull-pat" ) - await user.click(within(dialog).getByRole("button", { name: "Update token" })) + await user.click( + within(dialog).getByRole("button", { name: "Update token" }) + ) await waitFor(() => { expect(mocks.updateKnotreeRegistryConnection).toHaveBeenCalledWith( "de305d54-75b4-431b-adb2-eb6b9e546014", @@ -788,7 +806,9 @@ describe("ResourceWorkspace settings pane", () => { expect(within(dialog).getByLabelText("Replace pull token")).toHaveValue("") await user.click( - within(dialog).getByRole("button", { name: "Disconnect Knotree Registry" }) + within(dialog).getByRole("button", { + name: "Disconnect Knotree Registry", + }) ) await waitFor(() => { expect(mocks.deleteKnotreeRegistryConnection).toHaveBeenCalledWith( @@ -1089,7 +1109,9 @@ describe("ResourceWorkspace settings pane", () => { within(dialog).queryByRole("tab", { name: "Database" }) ).not.toBeInTheDocument() await user.click(within(dialog).getByRole("tab", { name: "Analytics" })) - expect(await within(dialog).findByRole("heading", { name: "Page analytics" })).toBeInTheDocument() + expect( + await within(dialog).findByRole("heading", { name: "Page analytics" }) + ).toBeInTheDocument() expect(within(dialog).getByText("Pageviews")).toBeInTheDocument() expect(within(dialog).getByText("Chrome")).toBeInTheDocument() expect(mocks.getHtmlPageAnalytics).toHaveBeenCalledWith( diff --git a/apps/web/src/components/resource-workspace.tsx b/apps/web/src/components/resource-workspace.tsx index 9826659..8ff95d2 100644 --- a/apps/web/src/components/resource-workspace.tsx +++ b/apps/web/src/components/resource-workspace.tsx @@ -28,29 +28,26 @@ import { cn } from "cn" import { Button } from "@/components/ui/button" import { AppServiceDeploymentLogs } from "@/components/app-service-deployment-logs" import { AppServiceRuntimeLogs } from "@/components/app-service-runtime-logs" +import { RegistryConnectionPanel } from "@/components/registry-connection-panel" import { HtmlAnalyticsPane, HtmlSourceEditor, } from "@/components/html-page-workspace" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { Spinner } from "@/components/ui/spinner" import { Textarea } from "@/components/ui/textarea" import { ApiError } from "@/lib/api" import { createDatabaseTable, executeDatabaseQuery, - deleteKnotreeRegistryConnection, getAppServiceMetrics, getDatabaseConfig, getDatabaseMetrics, getDatabaseStats, getDatabaseTableData, listDatabaseTables, - listAppServices, listPostgresResources, retryPostgresResource, - updateKnotreeRegistryConnection, updateAppService, updateAppServiceAutoDeploy, updateAppServiceDatabase, @@ -2682,12 +2679,8 @@ function AutoDeployEditor({ const isGithubImage = appService.imageSource === "github" || appService.imageSource === "html_github" - const isKnotreeRegistryImage = appService.imageSource === "knotree_registry" const [enabled, setEnabled] = useState(appService.autoDeployEnabled ?? false) const [isSaving, setIsSaving] = useState(false) - const [isDisconnecting, setIsDisconnecting] = useState(false) - const [isRotatingToken, setIsRotatingToken] = useState(false) - const [replacementRegistryToken, setReplacementRegistryToken] = useState("") const [error, setError] = useState(null) const isBusy = isSaving || appService.status === "provisioning" @@ -2706,17 +2699,13 @@ function AutoDeployEditor({ setEnabled(resource.autoDeployEnabled ?? nextEnabled) onAppServiceUpdated?.(resource) onToast( - isKnotreeRegistryImage - ? nextEnabled - ? "Automatic Knotree Registry deploys enabled." - : "Automatic Knotree Registry deploys disabled." - : nextEnabled - ? appService.imageSource === "html_github" - ? "Automatic GitHub HTML deploys enabled." - : "Automatic GitHub image deploys enabled." - : appService.imageSource === "html_github" - ? "Automatic GitHub HTML deploys disabled." - : "Automatic GitHub image deploys disabled." + nextEnabled + ? appService.imageSource === "html_github" + ? "Automatic GitHub HTML deploys enabled." + : "Automatic GitHub image deploys enabled." + : appService.imageSource === "html_github" + ? "Automatic GitHub HTML deploys disabled." + : "Automatic GitHub image deploys disabled." ) } catch (caught) { setEnabled(appService.autoDeployEnabled ?? false) @@ -2730,60 +2719,6 @@ function AutoDeployEditor({ } } - async function disconnectRegistry() { - const connectionId = appService.registryConnectionId - if (!connectionId) return - setIsDisconnecting(true) - setError(null) - try { - await deleteKnotreeRegistryConnection( - workspaceId, - projectSlug, - connectionId - ) - const services = await listAppServices(workspaceId, projectSlug) - const updated = services.find((service) => service.id === appService.id) - if (updated) onAppServiceUpdated?.(updated) - onToast( - "Cloud connection removed. Revoke the PAT in Knotree Registry too if you no longer need it. The running service stays up; future Cloud pulls and auto-deploys are stopped." - ) - } catch (caught) { - setError( - caught instanceof ApiError - ? caught.message - : "The Knotree Registry connection could not be disconnected." - ) - } finally { - setIsDisconnecting(false) - } - } - - async function rotateRegistryToken(event: React.FormEvent) { - event.preventDefault() - const connectionId = appService.registryConnectionId - if (!connectionId || !replacementRegistryToken.trim()) return - setIsRotatingToken(true) - setError(null) - try { - await updateKnotreeRegistryConnection( - workspaceId, - projectSlug, - connectionId, - replacementRegistryToken.trim() - ) - setReplacementRegistryToken("") - onToast("Knotree Registry token updated for this project connection.") - } catch (caught) { - setError( - caught instanceof ApiError - ? caught.message - : "The Knotree Registry token could not be updated." - ) - } finally { - setIsRotatingToken(false) - } - } - return (
@@ -2794,11 +2729,7 @@ function AutoDeployEditor({ : "Deploy new image digests"}

- {isKnotreeRegistryImage - ? appService.registryConnectionId - ? "Knotree Registry sends signed tag updates. Cloud deploys the exact new digest for this configured repository and tag." - : "Registry access has been disconnected. The current service continues running, but it cannot pull new images until you create a service with a new connection." - : isGithubImage + {isGithubImage ? appService.imageSource === "html_github" ? "Knotree checks the GitHub HTML repo every minute and redeploys when a new commit is pushed." : "Knotree checks this GHCR tag every minute and redeploys only when the image changes." @@ -2809,18 +2740,12 @@ function AutoDeployEditor({ void handleChange(event)} /> {isSaving ? "Saving…" : enabled ? "Enabled" : "Disabled"} @@ -2834,23 +2759,9 @@ function AutoDeployEditor({ ? "GitHub HTML repository" : isGithubImage ? "GitHub Container Registry" - : isKnotreeRegistryImage - ? "Knotree Registry" - : "Docker registry"} + : "Docker registry"}

- {isKnotreeRegistryImage ? ( -
-
Connection
-
{appService.registryConnectionId ? "Connected" : "Disconnected"}
-
- ) : null} - {isKnotreeRegistryImage ? ( -
-
Configured image
-
{appService.image}
-
- ) : null}
Deployed digest
@@ -2858,7 +2769,7 @@ function AutoDeployEditor({
-
{isKnotreeRegistryImage ? "Last Registry event" : "Last check"}
+
Last check
{appService.autoDeployCheckedAt ? new Date(appService.autoDeployCheckedAt).toLocaleString() @@ -2866,53 +2777,6 @@ function AutoDeployEditor({
- {isKnotreeRegistryImage && appService.registryConnectionId ? ( -
-
- -
- - setReplacementRegistryToken(event.target.value) - } - placeholder="Paste a new pull-only token" - /> - -
- - The replacement is verified for this repository and remains - encrypted. Disconnecting removes Cloud's saved connection; - revoke the PAT in Knotree Registry separately if needed. - -
- -
- ) : null} {appService.autoDeployError ? (

{appService.autoDeployError} @@ -3425,14 +3289,25 @@ function SettingsPane({

{section.description}

{section.id === "auto-deploy" && appService ? (
- + {appService.imageSource === "knotree_registry" ? ( + + ) : ( + + )}
) : (
diff --git a/apps/web/src/lib/registry-consent.ts b/apps/web/src/lib/registry-consent.ts new file mode 100644 index 0000000..841435a --- /dev/null +++ b/apps/web/src/lib/registry-consent.ts @@ -0,0 +1,21 @@ +const REGISTRY_ORIGIN = "https://registry.knotree.com" +const AUTHORIZE_PATH = /^\/cloud\/authorize\/[0-9a-f-]{36}$/ + +/** + * Returns the Registry consent page to open, or throws when the API returned + * anything other than the exact Registry authorization route. + */ +export function registryAuthorizationTarget(authorizationUrl: string) { + const target = new URL(authorizationUrl) + if ( + target.origin !== REGISTRY_ORIGIN || + !AUTHORIZE_PATH.test(target.pathname) || + target.search || + target.hash || + target.username || + target.password + ) { + throw new Error("Registry returned an invalid authorization URL.") + } + return target.href +} diff --git a/apps/web/src/lib/resources.ts b/apps/web/src/lib/resources.ts index 9fe0e6e..948c3ca 100644 --- a/apps/web/src/lib/resources.ts +++ b/apps/web/src/lib/resources.ts @@ -19,6 +19,7 @@ import type { ImageKeyAccess, ImageObjectList, ImageStore, + RegistryDeployHistory, PostgresResource, RedisResource, SignedImageUrl, @@ -151,6 +152,28 @@ export function deleteKnotreeRegistryConnection( ) } +export function attachAppServiceRegistryConnection( + workspaceId: string, + projectSlug: string, + appServiceId: string, + connectionId: string +) { + return apiRequest( + `${appServicesPath(workspaceId, projectSlug)}/${encodeURIComponent(appServiceId)}/registry-connection`, + { method: "PATCH", body: { connectionId } } + ) +} + +export function listRegistryDeploys( + workspaceId: string, + projectSlug: string, + appServiceId: string +) { + return apiRequest( + `${appServicesPath(workspaceId, projectSlug)}/${encodeURIComponent(appServiceId)}/registry-deploys` + ) +} + export function appServiceDeploymentEventsUrl( workspaceId: string, projectSlug: string, diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 0793667..1d9e3c1 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -129,6 +129,26 @@ export type KnotreeRegistryConnectionList = { consentReady?: boolean } +export type RegistryDeployJobStatus = + "pending" | "running" | "succeeded" | "failed" + +export type RegistryDeployJob = { + id: string + imageDigest: string + status: RegistryDeployJobStatus + attemptCount: number + lastError: string | null + deploymentId: string | null + receivedAt: string + updatedAt: string +} + +export type RegistryDeployHistory = { + appServiceId: string + autoDeployReady: boolean + jobs: RegistryDeployJob[] +} + export type DatabaseTable = { schemaName: string tableName: string diff --git a/docs/product/knotree-registry-integration.md b/docs/product/knotree-registry-integration.md index 55274be..797af6f 100644 --- a/docs/product/knotree-registry-integration.md +++ b/docs/product/knotree-registry-integration.md @@ -176,6 +176,8 @@ the workspace/project before reading or mutating a connection: | `POST /api/v1/workspaces/{workspace}/projects/{project}/registry-connections` | Accept `{ username, token, repository }`; verify pull access to the fixed Registry host, encrypt the PAT, and return metadata only. | | `GET /api/v1/workspaces/{workspace}/projects/{project}/registry-connections` | List non-secret metadata plus `autoDeployReady` for that project's connections. | | `PATCH /api/v1/workspaces/{workspace}/projects/{project}/registry-connections/{id}` | Verify and rotate the PAT; update per-service Kubernetes pull secrets before replacing the encrypted credential. | +| `PATCH /api/v1/workspaces/{workspace}/projects/{project}/app-services/{service}/registry-connection` | Accept `{ connectionId }`; attach an active project connection for the service's exact repository to an existing Registry service, refresh its Kubernetes pull secret, and clear the disconnect error. | +| `GET /api/v1/workspaces/{workspace}/projects/{project}/app-services/{service}/registry-deploys` | Return `autoDeployReady` and the service's 10 most recent Registry deploy jobs (digest, status, attempts, user-safe error, timestamps). | | `DELETE /api/v1/workspaces/{workspace}/projects/{project}/registry-connections/{id}` | Revoke the Cloud connection, disable dependent auto-deploy services, detach the connection, fail pending jobs, and attempt to delete per-service Kubernetes pull secrets. Cleanup errors are logged. Running services are left online. This does not revoke the PAT at Registry. | | `POST /api/v1/public/webhooks/knotree-registry` | Receive a Registry delivery without a browser cookie; authenticate with the configured HMAC secret. Body limit is 64 KiB. | @@ -314,13 +316,19 @@ secret. - The create action verifies/saves the connection before creating the service; the API response never includes the token. Automatic deploy is disabled in the form when `autoDeployReady` is false. -- Settings shows the configured image/tag, connected/disconnected state, - deployed digest, and Registry auto-deploy control. It supports token - rotation and disconnect. Rotation requires a fresh PAT. Disconnect revokes - only Cloud's saved connection; the PAT itself must also be revoked in the - Registry dashboard if it should no longer be usable. After disconnect, the - existing service remains running, but must be recreated with a new - connection to resume Registry pulls. +- Settings shows a Registry connection panel: a Live / Paused / Unavailable / + Disconnected status, the push → signed event → deploy flow, the watched + repository and tag, deployed digest, last Registry event, the auto-deploy + control, and the last 10 pushes with their outcome (Queued, Deploying, + Deployed, Skipped when the digest was already running or superseded, Failed + with its error). It supports token rotation and disconnect. Rotation + requires a fresh PAT. Disconnect revokes only Cloud's saved connection; the + PAT itself must also be revoked in the Registry dashboard if it should no + longer be usable. After disconnect, the existing service remains running and + can be reconnected in place: pick a saved project connection for the same + repository, authorize on Registry, or paste a new pull-only PAT. The service + keeps its URL, variables and database; auto deploy stays off until the user + turns it back on. ## Implementation and release status