diff --git a/core/frontend/src/components/kraken/KrakenManager.ts b/core/frontend/src/components/kraken/KrakenManager.ts index 7637748741..d9b42eb1e3 100644 --- a/core/frontend/src/components/kraken/KrakenManager.ts +++ b/core/frontend/src/components/kraken/KrakenManager.ts @@ -1,9 +1,11 @@ import { ExtensionData, + ExtensionUploadResponse, InstalledExtensionData, Manifest, ManifestSource, RunningContainer, + UploadProgressEvent, } from '@/types/kraken' import back_axios from '@/utils/api' @@ -335,6 +337,61 @@ export async function getContainerLogs( }) } +/** + * Upload a tar file containing a Docker image and extract metadata + * @param {File} file The tar file to upload + * @returns {Promise<{temp_tag: string, metadata: any, image_name: string}>} + */ +export async function uploadExtensionTarFile( + file: File, + progressHandler?: (event: UploadProgressEvent) => void, +): Promise { + const formData = new FormData() + formData.append('file', file) + + const response = await back_axios({ + method: 'POST', + url: `${KRAKEN_API_V2_URL}/extension/upload`, + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 120000, + onUploadProgress: progressHandler, + }) + + return response.data +} + +/** + * Finalize a temporary extension by assigning a valid identifier and installing it + * @param {InstalledExtensionData} extension The extension data to finalize + * @param {string} tempTag The temporary tag from upload response + * @param {function} progressHandler The progress handler for the download + * @returns {Promise} + */ +export async function finalizeExtension( + extension: InstalledExtensionData, + tempTag: string, + progressHandler: (event: any) => void, +): Promise { + await back_axios({ + method: 'POST', + url: `${KRAKEN_API_V2_URL}/extension/finalize?temp_tag=${tempTag}`, + data: { + identifier: extension.identifier, + name: extension.name, + docker: extension.docker, + tag: extension.tag, + enabled: true, + permissions: extension?.permissions ?? '', + user_permissions: extension?.user_permissions ?? '', + }, + timeout: 120000, + onDownloadProgress: progressHandler, + }) +} + export default { fetchManifestSources, fetchManifestSource, @@ -357,4 +414,6 @@ export default { listContainers, getContainersStats, getContainerLogs, + uploadExtensionTarFile, + finalizeExtension, } diff --git a/core/frontend/src/components/kraken/modals/ExtensionCreationModal.vue b/core/frontend/src/components/kraken/modals/ExtensionCreationModal.vue index 55a50702a2..eef2196605 100644 --- a/core/frontend/src/components/kraken/modals/ExtensionCreationModal.vue +++ b/core/frontend/src/components/kraken/modals/ExtensionCreationModal.vue @@ -9,7 +9,7 @@ > - {{ is_editing ? 'Edit' : 'Create' }} Extension + {{ is_editing ? 'Edit' : (is_from_upload ? 'Configure Uploaded' : 'Create') }} Extension - {{ is_editing ? 'Save' : 'Create' }} + {{ is_editing ? 'Save' : (is_from_upload ? 'Install' : 'Create') }} @@ -118,6 +118,10 @@ export default Vue.extend({ type: Object as PropType, default: null, }, + tempTag: { + type: String, + default: null, + }, }, data() { @@ -141,6 +145,9 @@ export default Vue.extend({ is_editing() { return this.extension?.editing ?? false }, + is_from_upload() { + return Boolean(this.tempTag) + }, is_reset_editing_permissions_visible() { return this.new_permissions !== this.extension?.permissions }, diff --git a/core/frontend/src/types/kraken.ts b/core/frontend/src/types/kraken.ts index c57d960a37..f90e710372 100644 --- a/core/frontend/src/types/kraken.ts +++ b/core/frontend/src/types/kraken.ts @@ -94,8 +94,27 @@ export interface Manifest extends ManifestSource { data?: [ExtensionData] } -export interface ProgressEvent { +export interface StreamProgressEvent { currentTarget: { response: string } -} \ No newline at end of file +} + +export interface UploadProgressEvent { + loaded: number + total?: number +} + +export interface ExtensionUploadMetadata { + identifier?: string + name?: string + docker?: string + tag?: string + permissions?: JSONValue +} + +export interface ExtensionUploadResponse { + temp_tag: string + metadata: ExtensionUploadMetadata + image_name: string +} diff --git a/core/frontend/src/views/ExtensionManagerView.vue b/core/frontend/src/views/ExtensionManagerView.vue index e97eb81768..f6185b5a15 100644 --- a/core/frontend/src/views/ExtensionManagerView.vue +++ b/core/frontend/src/views/ExtensionManagerView.vue @@ -23,6 +23,185 @@ v-model="show_settings" @refresh="fetchManifest" /> + + + +
+
+ Install extension from file +
+
+ Upload a Kraken-compatible .tar archive to sideload an extension. +
+
+
+ + Installed + + + mdi-close + +
+
+ + + + + +
+ + Upload file + + + Reset + + + Configure & Install + +
+ + {{ install_from_file_error }} + + + Extension installed successfully. + +
+ +
+
+
+ + {{ phase.icon }} + +
+
+ {{ phase.label }} +
+
+ {{ phase.description }} +
+
+
+ +
+ {{ phase.progressText }} +
+
+
+
+
+ + + +
+ + + Close + + +
+
- - mdi-plus - + + + mdi-file-upload-outline + + + mdi-code-braces + + @@ -204,10 +416,52 @@ import PullTracker from '@/utils/pull_tracker' import { aggregateStreamingResponse, parseStreamingResponse } from '@/utils/streaming' import { - ExtensionData, InstalledExtensionData, ProgressEvent, - RunningContainer, + ExtensionData, ExtensionUploadMetadata, InstalledExtensionData, RunningContainer, + StreamProgressEvent, UploadProgressEvent, } from '../types/kraken' +type TarInstallPhase = 'idle' | 'selected' | 'uploading' | 'processing' | 'ready' | 'installing' | 'success' | 'error' +type TarStepKey = 'upload' | 'load' | 'configure' | 'install' +type TarStepStatus = 'pending' | 'active' | 'complete' | 'error' + +interface UploadPhaseDescriptor { + key: TarStepKey + label: string + description: string + icon: string + status: TarStepStatus + showProgress?: boolean + progress?: number + indeterminate?: boolean + progressText?: string +} + +const TAR_PHASE_LEVEL: Record, number> = { + idle: -1, + selected: 0, + uploading: 0, + processing: 1, + ready: 2, + installing: 3, + success: 4, +} + +const TAR_STEP_ORDER: TarStepKey[] = ['upload', 'load', 'configure', 'install'] + +function currentStepForPhase(phase: TarInstallPhase): TarStepKey { + switch (phase) { + case 'processing': + return 'load' + case 'ready': + return 'configure' + case 'installing': + case 'success': + return 'install' + default: + return 'upload' + } +} + const notifier = new Notifier(kraken_service) const ansi = new AnsiUp() @@ -232,6 +486,7 @@ export default Vue.extend({ settings, show_dialog: false, show_settings: false, + show_install_from_file_dialog: false, installed_extensions: {} as Dictionary, selected_extension: null as (null | ExtensionData), running_containers: [] as RunningContainer[], @@ -256,6 +511,18 @@ export default Vue.extend({ fetch_running_containers_task: new OneMoreTime({ delay: 10000, disposeWith: this }), fetch_containers_stats_task: new OneMoreTime({ delay: 25000, disposeWith: this }), outputBuffer: '', + fab_menu: false, + upload_temp_tag: null as null | string, + upload_metadata: null as null | ExtensionUploadMetadata, + selected_tar_file: null as null | File, + file_uploading: false, + install_from_file_phase: 'idle' as TarInstallPhase, + install_from_file_upload_progress: 0, + install_from_file_install_progress: 0, + install_from_file_status_text: '', + install_from_file_error: null as null | string, + install_from_file_failed_step: null as null | TarStepKey, + install_from_file_last_level: -1, } }, computed: { @@ -278,6 +545,77 @@ export default Vue.extend({ html_log_output(): string { return ansi.ansi_to_html(this.log_output ?? '') }, + canUploadSelectedFile(): boolean { + return Boolean( + this.selected_tar_file + && !this.file_uploading + && this.install_from_file_phase !== 'installing' + && this.install_from_file_phase !== 'processing', + ) + }, + canConfigureUploaded(): boolean { + return this.install_from_file_phase === 'ready' && Boolean(this.upload_metadata && this.upload_temp_tag) + }, + uploadPhases(): UploadPhaseDescriptor[] { + const uploadStatus = this.getTarStepStatus('upload') + const loadStatus = this.getTarStepStatus('load') + const configureStatus = this.getTarStepStatus('configure') + const installStatus = this.getTarStepStatus('install') + + const installProgress = Number.isFinite(this.install_from_file_install_progress) + ? this.install_from_file_install_progress + : 0 + + let uploadProgressText: string | undefined + if (uploadStatus === 'complete') { + uploadProgressText = 'Upload finished' + } else if (uploadStatus === 'active' && this.install_from_file_upload_progress > 0) { + uploadProgressText = `${Math.round(this.install_from_file_upload_progress)}%` + } + + return [ + { + key: 'upload', + label: 'Upload file', + description: 'Transfer the .tar archive to Kraken', + icon: 'mdi-upload', + status: uploadStatus, + showProgress: true, + progress: this.install_from_file_upload_progress, + indeterminate: uploadStatus === 'active' && !this.install_from_file_upload_progress, + progressText: uploadProgressText, + }, + { + key: 'load', + label: 'Load Docker image', + description: 'Import image and inspect metadata', + icon: 'mdi-docker', + status: loadStatus, + showProgress: true, + progress: loadStatus === 'complete' ? 100 : 0, + indeterminate: loadStatus === 'active', + progressText: loadStatus === 'complete' ? 'Image ready' : undefined, + }, + { + key: 'configure', + label: 'Configure metadata', + description: 'Review identifier, name, and permissions', + icon: 'mdi-clipboard-text-edit', + status: configureStatus, + }, + { + key: 'install', + label: 'Install extension', + description: 'Stream Docker pull progress', + icon: 'mdi-progress-download', + status: installStatus, + showProgress: true, + progress: installStatus === 'complete' ? 100 : installProgress, + indeterminate: installStatus === 'active' && !installProgress, + progressText: this.install_from_file_status_text || undefined, + }, + ] + }, }, watch: { show_log: { @@ -298,6 +636,11 @@ export default Vue.extend({ } }, }, + show_install_from_file_dialog(val: boolean) { + if (!val) { + this.resetUploadFlow() + } + }, }, mounted() { this.fetchManifest() @@ -312,6 +655,107 @@ export default Vue.extend({ clearEditedExtension() { this.edited_extension = null }, + setInstallFromFilePhase(phase: TarInstallPhase) { + this.install_from_file_phase = phase + if (phase !== 'error') { + this.install_from_file_error = null + this.install_from_file_failed_step = null + this.install_from_file_last_level = TAR_PHASE_LEVEL[phase] + } + }, + getTarStepStatus(stepKey: TarStepKey): TarStepStatus { + if (this.install_from_file_phase === 'error' && this.install_from_file_failed_step === stepKey) { + return 'error' + } + const currentLevel = this.install_from_file_phase === 'error' + ? this.install_from_file_last_level + : TAR_PHASE_LEVEL[this.install_from_file_phase] + const stepIndex = TAR_STEP_ORDER.indexOf(stepKey) + if (currentLevel > stepIndex) { + return 'complete' + } + if (currentLevel === stepIndex && this.install_from_file_phase !== 'error') { + return 'active' + } + return 'pending' + }, + currentTarStepKey(): TarStepKey { + return currentStepForPhase(this.install_from_file_phase) + }, + applyInstallFromFileError(message: string) { + this.install_from_file_error = message + this.install_from_file_failed_step = this.currentTarStepKey() + this.install_from_file_status_text = '' + this.install_from_file_phase = 'error' + }, + openInstallFromFileDialog(): void { + this.fab_menu = false + this.show_install_from_file_dialog = true + }, + closeInstallFromFileDialog(): void { + this.show_install_from_file_dialog = false + }, + resetUploadFlow(options: { keepFile?: boolean } = {}) { + if (!options.keepFile) { + this.selected_tar_file = null + } + this.upload_temp_tag = null + this.upload_metadata = null + this.install_from_file_upload_progress = 0 + this.install_from_file_install_progress = 0 + this.install_from_file_status_text = '' + this.install_from_file_error = null + this.install_from_file_failed_step = null + this.install_from_file_last_level = -1 + this.setInstallFromFilePhase('idle') + this.file_uploading = false + }, + onTarFileSelected(file: File | File[] | null) { + const selected = Array.isArray(file) ? file[0] : file + if (!selected) { + this.resetUploadFlow() + return + } + this.resetUploadFlow({ keepFile: true }) + this.selected_tar_file = selected + this.setInstallFromFilePhase('selected') + }, + handleFileUploadProgress(progressEvent: UploadProgressEvent) { + if (!progressEvent.total || progressEvent.total <= 0) { + this.setInstallFromFilePhase('uploading') + return + } + const ratio = progressEvent.loaded / progressEvent.total + const percent = Math.min(100, Math.round(ratio * 100)) + this.install_from_file_upload_progress = percent + if (percent >= 100) { + this.setInstallFromFilePhase('processing') + } else if (this.install_from_file_phase !== 'uploading') { + this.setInstallFromFilePhase('uploading') + } + }, + async uploadSelectedTarFile(): Promise { + if (!this.selected_tar_file || this.file_uploading) { + return + } + this.install_from_file_upload_progress = 0 + this.setInstallFromFilePhase('uploading') + this.file_uploading = true + try { + const response = await kraken.uploadExtensionTarFile( + this.selected_tar_file, + (progressEvent) => this.handleFileUploadProgress(progressEvent), + ) + this.upload_temp_tag = response.temp_tag + this.upload_metadata = response.metadata + this.setInstallFromFilePhase('ready') + } catch (error) { + this.applyInstallFromFileError(String(error)) + notifier.pushBackError('EXTENSION_UPLOAD_FAIL', error) + } finally { + this.file_uploading = false + } + }, async update(extension: InstalledExtensionData, version: string) { this.show_pull_output = true const tracker = this.getTracker() @@ -342,10 +786,21 @@ export default Vue.extend({ return } - await this.install(this.edited_extension) + const isUploadFlow = Boolean(this.upload_temp_tag) + + if (isUploadFlow) { + // This is from a file upload, use finalize endpoint + await this.finalizeUploadedExtension(this.edited_extension) + } else { + // This is a regular extension creation + await this.install(this.edited_extension) + } this.show_dialog = false this.edited_extension = null + if (!isUploadFlow) { + this.upload_metadata = null + } }, openEditDialog(extension: InstalledExtensionData): void { this.edited_extension = { ...extension, editing: true } @@ -598,14 +1053,23 @@ export default Vue.extend({ this.extraction_percentage = 0 this.status_text = '' }, - handleDownloadProgress(progressEvent: ProgressEvent, tracker: PullTracker) { + handleDownloadProgress(progressEvent: StreamProgressEvent, tracker: PullTracker) { tracker.digestNewData(progressEvent) this.pull_output = tracker.pull_output this.download_percentage = tracker.download_percentage this.extraction_percentage = tracker.extraction_percentage this.status_text = tracker.overall_status + if (this.install_from_file_phase === 'installing') { + const percent = Number.isFinite(tracker.download_percentage) + ? Math.min(100, Math.max(0, Math.round(tracker.download_percentage))) + : 0 + this.install_from_file_install_progress = percent + if (tracker.overall_status) { + this.install_from_file_status_text = tracker.overall_status + } + } }, - handleLogProgress(progressEvent: ProgressEvent, extension: InstalledExtensionData) { + handleLogProgress(progressEvent: StreamProgressEvent, extension: InstalledExtensionData) { const result = aggregateStreamingResponse( parseStreamingResponse(progressEvent.currentTarget.response), (_, buffer) => Boolean(buffer), @@ -623,6 +1087,56 @@ export default Vue.extend({ } }) }, + openCreationDialogFromUpload(metadata: ExtensionUploadMetadata | null = this.upload_metadata): void { + if (!metadata) { + return + } + const serializedPermissions = typeof metadata.permissions === 'string' + ? metadata.permissions + : JSON.stringify(metadata.permissions || {}) + + this.edited_extension = { + identifier: metadata.identifier || 'yourorganization.yourextension', + name: metadata.name || '', + docker: metadata.docker || '', + tag: metadata.tag || 'latest', + enabled: true, + permissions: serializedPermissions, + user_permissions: serializedPermissions, + editing: false, + } + }, + async finalizeUploadedExtension(extension: InstalledExtensionData): Promise { + if (!this.upload_temp_tag) { + return + } + + this.show_pull_output = true + const tracker = this.getTracker() + this.setInstallFromFilePhase('installing') + this.install_from_file_install_progress = 0 + this.install_from_file_status_text = 'Starting installation...' + + try { + await kraken.finalizeExtension( + extension, + this.upload_temp_tag, + (progressEvent) => this.handleDownloadProgress(progressEvent.event, tracker), + ) + this.setInstallFromFilePhase('success') + this.install_from_file_status_text = 'Extension installed successfully' + this.upload_temp_tag = null + this.upload_metadata = null + this.fetchInstalledExtensions() + } catch (error) { + this.applyInstallFromFileError(String(error)) + this.alerter = true + this.alerter_error = String(error) + notifier.pushBackError('EXTENSION_FINALIZE_FAIL', error) + } finally { + this.resetPullOutput() + } + }, }, }) @@ -680,4 +1194,51 @@ pre.logs { max-height: calc(80vh - 64px); overflow-y: auto; } + +.tar-upload-card { + background-color: #ffffff !important; +} + +.theme--dark .tar-upload-card { + background-color: #0f172a !important; +} + +.tar-upload-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.upload-phase-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.upload-phase__header { + display: flex; + align-items: flex-start; +} + +.upload-phase__title { + font-weight: 600; +} + +.upload-phase.complete .upload-phase__title { + color: #4CAF50; +} + +.upload-phase.active .upload-phase__title { + color: #2196F3; +} + +.upload-phase.error .upload-phase__title { + color: #FF5252; +} + +.metadata-preview__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px 24px; +} diff --git a/core/services/kraken/api/v2/routers/extension.py b/core/services/kraken/api/v2/routers/extension.py index a23eed288d..8ce985f171 100644 --- a/core/services/kraken/api/v2/routers/extension.py +++ b/core/services/kraken/api/v2/routers/extension.py @@ -3,14 +3,16 @@ from typing import Any, Callable, List, Tuple, cast from commonwealth.utils.streaming import streamer -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status from fastapi.responses import Response, StreamingResponse from fastapi_versioning import versioned_api_route +from loguru import logger from extension.exceptions import ( ExtensionInsufficientStorage, ExtensionNotFound, ExtensionNotRunning, + ExtensionPullFailed, ) from extension.extension import Extension from extension.models import ExtensionSource @@ -34,6 +36,8 @@ async def wrapper(*args: Tuple[Any], **kwargs: dict[str, Any]) -> Any: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error except ExtensionInsufficientStorage as error: raise HTTPException(status_code=status.HTTP_507_INSUFFICIENT_STORAGE, detail=str(error)) from error + except ExtensionPullFailed as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error except HTTPException as error: raise error except Exception as error: @@ -173,3 +177,71 @@ async def uninstall_version(identifier: str, tag: str) -> None: """ extension = cast(Extension, await Extension.from_settings(identifier, tag)) await extension.uninstall() + + +@extension_router_v2.post("/upload", status_code=status.HTTP_201_CREATED) +@extension_to_http_exception +async def upload_tar_file(file: UploadFile = File(...)) -> dict[str, Any]: + """ + Upload a tar file containing a Docker image, load it, inspect it, and create a temporary extension. + Returns extracted metadata that can be edited before finalizing the installation. + """ + + if not file.filename or not file.filename.endswith(".tar"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File must be a .tar file") + + # Read uploaded file content directly - no need for temp file since import_image accepts bytes + try: + content = await file.read() + + # Load image from tar file + logger.info(f"Loading image from tar file: {file.filename}") + image_name = await Extension.load_image_from_tar(content) + logger.info(f"Image loaded: {image_name}") + + # Inspect image to extract metadata + metadata = await Extension.inspect_image_labels(image_name) + + # Create temporary extension + temp_extension = await Extension.create_temporary_extension(image_name, metadata) + + # Return metadata for frontend editing + return { + "temp_tag": temp_extension.tag, + "metadata": metadata, + "image_name": image_name, + } + except Exception as error: + logger.error(f"Failed to process tar file: {error}") + raise + + +@extension_router_v2.post("/finalize", status_code=status.HTTP_201_CREATED) +@extension_to_http_exception +async def finalize_extension( + body: ExtensionSource, temp_tag: str = Query(..., description="Temporary tag from upload response") +) -> StreamingResponse: + """ + Finalize a temporary extension by assigning a valid identifier and installing it. + The temp_tag identifies the temporary extension created from an uploaded tar file. + """ + if not body.identifier: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Identifier is required") + + # Use temp_tag from query param + search_tag = temp_tag + + # Find temporary extension by temp_tag + extensions: List[Extension] = cast(List[Extension], await Extension.from_settings()) + temp_extensions = [ext for ext in extensions if ext.tag == search_tag and not ext.identifier] + + if not temp_extensions: + raise ExtensionNotFound(f"Temporary extension with tag {search_tag} not found") + + temp_extension = temp_extensions[0] + + # Finalize the extension + new_extension = await Extension.finalize_temporary_extension(temp_extension, body.identifier, body) + + # Install the extension + return StreamingResponse(streamer(new_extension.install(atomic=True))) diff --git a/core/services/kraken/extension/extension.py b/core/services/kraken/extension/extension.py index 84b1ed39d9..0b543ff54f 100644 --- a/core/services/kraken/extension/extension.py +++ b/core/services/kraken/extension/extension.py @@ -2,6 +2,7 @@ import base64 import json import os +import tempfile import time from typing import Any, AsyncGenerator, Dict, List, Literal, Optional, Tuple, cast @@ -399,3 +400,232 @@ def get_compatible_digest(version: ExtensionVersion, identifier: str, validate_s ) return compatible_images[0].digest + + @staticmethod + async def load_image_from_tar(tar_content: bytes) -> str: + """ + Load a Docker image from tar file content and return the image name. + + Args: + tar_content: The binary content of the tar file + """ + async with DockerCtx() as client: + # Use import_image with stream=True to get async iterator + response = client.images.import_image(tar_content, stream=True) + # Response contains stream data with image info + # Extract image name from response + async for line in response: + if isinstance(line, dict) and "stream" in line: + stream = line["stream"] + if "Loaded image:" in stream or "Loaded image ID:" in stream: + # Extract image name from stream + parts = stream.strip().split() + if len(parts) >= 3: + image_name = parts[-1] + return image_name + elif isinstance(line, dict) and "aux" in line: + aux = line["aux"] + if "Tag" in aux: + return aux["Tag"] + # If we can't find it in stream, list images to find the newly loaded one + images = await client.images.list() + if images: + # Return the most recently loaded image (last in list) + return images[-1]["RepoTags"][0] if images[-1].get("RepoTags") else images[-1]["Id"] + raise ExtensionPullFailed("Failed to load image from tar file") + + @staticmethod + async def inspect_image_labels(image_name: str) -> Dict[str, Any]: + """ + Inspect a Docker image and extract metadata from LABELs. + Returns a dictionary with extracted metadata. + """ + async with DockerCtx() as client: + try: + image_info = await client.images.inspect(image_name) + labels = image_info.get("Config", {}).get("Labels", {}) + + metadata: Dict[str, Any] = {} + + # Extract version + if "version" in labels: + metadata["version"] = labels["version"] + + # Extract permissions (JSON string) + if "permissions" in labels: + try: + permissions = json.loads(labels["permissions"]) + metadata["permissions"] = json.dumps(permissions) + except json.JSONDecodeError: + metadata["permissions"] = labels["permissions"] + + # Extract authors (JSON array) + if "authors" in labels: + try: + authors = json.loads(labels["authors"]) + metadata["authors"] = authors + except json.JSONDecodeError: + metadata["authors"] = [] + + # Extract company (JSON object) + if "company" in labels: + try: + company = json.loads(labels["company"]) + metadata["company"] = company + except json.JSONDecodeError: + metadata["company"] = {} + + # Extract type + if "type" in labels: + metadata["type"] = labels["type"] + + # Extract readme URL + if "readme" in labels: + metadata["readme"] = labels["readme"] + + # Extract links (JSON object) + if "links" in labels: + try: + links = json.loads(labels["links"]) + metadata["links"] = links + except json.JSONDecodeError: + metadata["links"] = {} + + # Extract requirements + if "requirements" in labels: + metadata["requirements"] = labels["requirements"] + + # Extract image repo and tag for docker field + repo_tags = image_info.get("RepoTags", []) + if repo_tags: + repo_tag = repo_tags[0] + if ":" in repo_tag: + repo, tag = repo_tag.rsplit(":", 1) + metadata["docker"] = repo + metadata["tag"] = tag + else: + metadata["docker"] = repo_tag + metadata["tag"] = "latest" + else: + # Fallback to image ID + image_id = image_info.get("Id", "") + metadata["docker"] = image_id[:12] if image_id else "unknown" + metadata["tag"] = "latest" + + # Try to extract name from company or use a default + DEFAULT_EXTENSION_NAME = "Unknown Extension" + if "name" not in metadata: + if "company" in metadata and isinstance(metadata["company"], dict): + metadata["name"] = metadata["company"].get("name", DEFAULT_EXTENSION_NAME) + else: + metadata["name"] = metadata.get("docker", DEFAULT_EXTENSION_NAME).split("/")[-1] + + return metadata + except Exception as error: + raise ExtensionPullFailed(f"Failed to inspect image {image_name}") from error + + @classmethod + async def create_temporary_extension(cls, image_name: str, metadata: Dict[str, Any]) -> "Extension": + """ + Create a temporary extension with empty identifier to track the uploaded image. + """ + # Generate a unique tag for the temporary extension + import uuid + + temp_tag = f"temp-{uuid.uuid4().hex[:8]}" + + # Extract or set defaults + DEFAULT_EXTENSION_NAME = "Unknown Extension" + name = metadata.get("name", DEFAULT_EXTENSION_NAME) + docker = metadata.get("docker", image_name.split(":")[0] if ":" in image_name else image_name) + permissions = metadata.get("permissions", json.dumps({})) + + # Create temporary extension with empty identifier + temp_source = ExtensionSource( + identifier="", # Empty identifier marks it as temporary + tag=temp_tag, + name=name, + docker=docker, + enabled=False, + permissions=permissions, + user_permissions="", + ) + + extension = Extension(temp_source) + + # Save temporary extension settings + # Use temp_tag for the settings entry, but docker points to actual loaded image + temp_settings = ExtensionSettings( + identifier="", + name=name, + docker=docker, # This is the actual loaded image repo + tag=temp_tag, # Use temp_tag to identify this temporary entry + permissions=permissions, + enabled=False, + user_permissions="", + ) + extension._save_settings(temp_settings) + + # Tag the loaded image with the temp_tag for reference + async with DockerCtx() as client: + try: + await client.images.tag(image_name, f"{docker}:{temp_tag}") + except Exception as error: + logger.warning(f"Failed to tag image with temp tag: {error}") + + return extension + + @classmethod + async def finalize_temporary_extension( + cls, temp_extension: "Extension", identifier: str, source: ExtensionSource + ) -> "Extension": + """ + Finalize a temporary extension by assigning a valid identifier and updating settings. + """ + old_settings = temp_extension.settings + + # Create new extension with valid identifier + new_extension = Extension(source) + + # Remove old temporary extension + temp_extension._save_settings() + + # Save new extension + new_settings = ExtensionSettings( + identifier=identifier, + name=source.name, + docker=source.docker, + tag=source.tag, + permissions=source.permissions, + enabled=source.enabled, + user_permissions=source.user_permissions, + ) + new_extension._save_settings(new_settings) + + # Tag the image with the new identifier if needed + if old_settings.docker != source.docker or old_settings.tag != source.tag: + async with DockerCtx() as client: + try: + await client.images.tag( + f"{old_settings.docker}:{old_settings.tag}", f"{source.docker}:{source.tag}" + ) + except Exception as error: + logger.warning(f"Failed to tag image: {error}") + + return new_extension + + @classmethod + async def cleanup_temporary_extensions(cls) -> None: + """ + Clean up temporary extensions (those with empty identifiers) and their images. + """ + extensions: List[ExtensionSettings] = cls._fetch_settings() + temp_extensions = [ext for ext in extensions if not ext.identifier or ext.identifier == ""] + + for ext in temp_extensions: + try: + extension = Extension(ExtensionSource.from_settings(ext)) + await extension.uninstall() + logger.info(f"Cleaned up temporary extension {ext.docker}:{ext.tag}") + except Exception as error: + logger.warning(f"Failed to cleanup temporary extension {ext.docker}:{ext.tag}: {error}") diff --git a/core/services/kraken/kraken.py b/core/services/kraken/kraken.py index 962ed3a69b..34a1d847b6 100644 --- a/core/services/kraken/kraken.py +++ b/core/services/kraken/kraken.py @@ -134,6 +134,13 @@ async def kill_invalid_extensions(self) -> None: f"Invalid extension {extension.identifier}:{extension.tag} could not be uninstalled: {e}" ) + async def cleanup_temporary_extensions(self) -> None: + """ + Clean up temporary extensions (those with empty identifiers) that are older than 1 hour. + This helps prevent accumulation of abandoned temporary extensions from failed uploads. + """ + await Extension.cleanup_temporary_extensions() + async def kill_dangling_containers(self) -> None: # This can fail if docker daemon is not running try: @@ -168,6 +175,7 @@ async def start_cleaner_task(self) -> None: await self.setup_default_extensions() await self.kill_invalid_extensions() await self.kill_dangling_containers() + await self.cleanup_temporary_extensions() await asyncio.sleep(60)