Browse Source

gcr fallback and error handling

Denys Bashkatov 2 weeks ago
parent
commit
4c45732c38

+ 18 - 0
admin/app/components/file-manager/download-image-modal.tsx

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
 import Modal from '~/components/shared/modal/modal';
 import Modal from '~/components/shared/modal/modal';
 import FormInput from '~/components/shared/form/form-input';
 import FormInput from '~/components/shared/form/form-input';
 import FormButton from '~/components/shared/form/form-button';
 import FormButton from '~/components/shared/form/form-button';
+import FormSelect from '~/components/shared/form/form-select';
 import { useSubmit } from 'react-router';
 import { useSubmit } from 'react-router';
 
 
 interface DownloadImageModalProps {
 interface DownloadImageModalProps {
@@ -13,9 +14,15 @@ interface DownloadImageModalProps {
 export default function DownloadImageModal({ isOpen, onClose, currentPath }: DownloadImageModalProps) {
 export default function DownloadImageModal({ isOpen, onClose, currentPath }: DownloadImageModalProps) {
   const [imageUrl, setImageUrl] = useState('');
   const [imageUrl, setImageUrl] = useState('');
   const [imageTag, setImageTag] = useState('latest');
   const [imageTag, setImageTag] = useState('latest');
+  const [architecture, setArchitecture] = useState('amd64');
   const [isSubmitting, setIsSubmitting] = useState(false);
   const [isSubmitting, setIsSubmitting] = useState(false);
   const submit = useSubmit();
   const submit = useSubmit();
 
 
+  const architectureOptions = [
+    { value: 'amd64', label: 'AMD64 (x86_64)' },
+    { value: 'arm64', label: 'ARM64 (aarch64)' },
+  ];
+
   const handleSubmit = async () => {
   const handleSubmit = async () => {
     if (!imageUrl.trim()) return;
     if (!imageUrl.trim()) return;
 
 
@@ -27,6 +34,7 @@ export default function DownloadImageModal({ isOpen, onClose, currentPath }: Dow
           intent: 'downloadImage', 
           intent: 'downloadImage', 
           imageUrl: imageUrl.trim(), 
           imageUrl: imageUrl.trim(), 
           imageTag: imageTag.trim() || 'latest',
           imageTag: imageTag.trim() || 'latest',
+          architecture: architecture,
           currentPath: currentPath 
           currentPath: currentPath 
         },
         },
         { action: '', method: 'post' }
         { action: '', method: 'post' }
@@ -35,6 +43,7 @@ export default function DownloadImageModal({ isOpen, onClose, currentPath }: Dow
       // Reset form and close modal
       // Reset form and close modal
       setImageUrl('');
       setImageUrl('');
       setImageTag('latest');
       setImageTag('latest');
+      setArchitecture('amd64');
       onClose();
       onClose();
     } catch (error) {
     } catch (error) {
       console.error('Failed to download image:', error);
       console.error('Failed to download image:', error);
@@ -46,6 +55,7 @@ export default function DownloadImageModal({ isOpen, onClose, currentPath }: Dow
   const handleCancel = () => {
   const handleCancel = () => {
     setImageUrl('');
     setImageUrl('');
     setImageTag('latest');
     setImageTag('latest');
+    setArchitecture('amd64');
     onClose();
     onClose();
   };
   };
 
 
@@ -80,6 +90,14 @@ export default function DownloadImageModal({ isOpen, onClose, currentPath }: Dow
             placeholder="latest"
             placeholder="latest"
           />
           />
         </div>
         </div>
+
+        <FormSelect
+          id="architecture-select"
+          label="Architecture"
+          value={architecture}
+          onChange={setArchitecture}
+          options={architectureOptions}
+        />
         
         
         <div className="flex justify-end gap-2 pt-4">
         <div className="flex justify-end gap-2 pt-4">
           <FormButton
           <FormButton

+ 36 - 14
admin/app/components/shared/form/form-checkbox.tsx

@@ -1,23 +1,45 @@
+import React from 'react';
+
 interface FormCheckboxProps {
 interface FormCheckboxProps {
+  id: string;
+  label: string;
   checked: boolean;
   checked: boolean;
   onChange: (checked: boolean) => void;
   onChange: (checked: boolean) => void;
-  label?: string;
   disabled?: boolean;
   disabled?: boolean;
+  description?: string;
 }
 }
 
 
-export default function FormCheckbox({ checked, onChange, label, disabled = false }: FormCheckboxProps) {
+export default function FormCheckbox({ 
+  id, 
+  label, 
+  checked, 
+  onChange, 
+  disabled = false,
+  description 
+}: FormCheckboxProps) {
   return (
   return (
-    <label className="flex items-center gap-[8px] cursor-pointer disabled:cursor-not-allowed">
-      <input
-        type="checkbox"
-        checked={checked}
-        onChange={(e) => onChange(e.target.checked)}
-        disabled={disabled}
-        className="w-[16px] h-[16px] text-blue-600 border-gray-300 rounded focus:ring-blue-500 disabled:bg-gray-100"
-      />
-      {label && (
-        <span className="text-[14px] text-gray-700 disabled:text-gray-500">{label}</span>
-      )}
-    </label>
+    <div className="flex items-start">
+      <div className="flex items-center h-5">
+        <input
+          id={id}
+          type="checkbox"
+          checked={checked}
+          onChange={(e) => onChange(e.target.checked)}
+          disabled={disabled}
+          className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
+        />
+      </div>
+      <div className="ml-3 text-sm">
+        <label 
+          htmlFor={id} 
+          className={`font-medium ${disabled ? 'text-gray-400' : 'text-gray-700'}`}
+        >
+          {label}
+        </label>
+        {description && (
+          <p className="text-gray-500 mt-1">{description}</p>
+        )}
+      </div>
+    </div>
   );
   );
 } 
 } 

+ 37 - 24
admin/app/components/shared/form/form-select.tsx

@@ -1,34 +1,47 @@
-interface FormSelectOption {
-  value: string;
-  label: string;
-}
+import React from 'react';
 
 
 interface FormSelectProps {
 interface FormSelectProps {
+  id: string;
+  label: string;
   value: string;
   value: string;
   onChange: (value: string) => void;
   onChange: (value: string) => void;
-  options: FormSelectOption[];
-  placeholder?: string;
+  options: { value: string; label: string }[];
   disabled?: boolean;
   disabled?: boolean;
+  placeholder?: string;
 }
 }
 
 
-export default function FormSelect({ value, onChange, options, placeholder, disabled = false }: FormSelectProps) {
+export default function FormSelect({ 
+  id, 
+  label, 
+  value, 
+  onChange, 
+  options,
+  disabled = false,
+  placeholder
+}: FormSelectProps) {
   return (
   return (
-    <select
-      value={value}
-      onChange={(e) => onChange(e.target.value)}
-      disabled={disabled}
-      className="w-full h-[40px] px-[12px] border border-gray-300 rounded-md text-[14px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
-    >
-      {placeholder && (
-        <option value="" disabled>
-          {placeholder}
-        </option>
-      )}
-      {options.map((option) => (
-        <option key={option.value} value={option.value}>
-          {option.label}
-        </option>
-      ))}
-    </select>
+    <div>
+      <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
+        {label}
+      </label>
+      <select
+        id={id}
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        disabled={disabled}
+        className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
+      >
+        {placeholder && (
+          <option value="" disabled>
+            {placeholder}
+          </option>
+        )}
+        {options.map((option) => (
+          <option key={option.value} value={option.value}>
+            {option.label}
+          </option>
+        ))}
+      </select>
+    </div>
   );
   );
 } 
 } 

+ 78 - 12
admin/app/routes/file-manager/action.tsx

@@ -252,16 +252,23 @@ async function handleChunkUpload(formData: FormData): Promise<{ success: boolean
   }
   }
 }
 }
 
 
-async function downloadImage(imageUrl: string, imageTag: string, destPath: string): Promise<boolean> {
+async function downloadImage(imageUrl: string, imageTag: string, destPath: string, architecture: string = 'amd64'): Promise<boolean> {
   try {
   try {
     // Ensure destination directory exists
     // Ensure destination directory exists
     await fs.mkdir(destPath, { recursive: true });
     await fs.mkdir(destPath, { recursive: true });
     
     
     // Create filename from image URL and tag
     // Create filename from image URL and tag
     const imageName = imageUrl.replace(/[^a-zA-Z0-9.-]/g, '_');
     const imageName = imageUrl.replace(/[^a-zA-Z0-9.-]/g, '_');
-    const fileName = `${imageName}_${imageTag}.tar`;
+    const fileName = `${imageName}_${imageTag}_${architecture}.tar`;
     const fullPath = path.join(destPath, fileName);
     const fullPath = path.join(destPath, fileName);
     
     
+    // Remove existing file if it exists to prevent modification errors
+    try {
+      await fs.unlink(fullPath);
+    } catch (unlinkError) {
+      // File doesn't exist, which is fine
+    }
+    
     // Parse registry and image details
     // Parse registry and image details
     const registryInfo = parseImageUrl(imageUrl);
     const registryInfo = parseImageUrl(imageUrl);
     if (!registryInfo) {
     if (!registryInfo) {
@@ -270,13 +277,65 @@ async function downloadImage(imageUrl: string, imageTag: string, destPath: strin
     
     
     // Use skopeo to copy image to tar format
     // Use skopeo to copy image to tar format
     const sourceImage = `${registryInfo.registry}/${registryInfo.repository}:${imageTag}`;
     const sourceImage = `${registryInfo.registry}/${registryInfo.repository}:${imageTag}`;
-    const skopeoCommand = `skopeo copy docker://${sourceImage} docker-archive:${fullPath}`;
-    
-    await execAsync(skopeoCommand);
+    const archFlag = `--override-arch ${architecture}`;
+    const skopeoCommand = `skopeo copy ${archFlag} docker://${sourceImage} docker-archive:${fullPath}`;
     
     
-    return true;
+    try {
+      await execAsync(skopeoCommand);
+      return true;
+    } catch (dockerError) {
+      // If Docker Hub fails, try GCR as fallback (only for single-word images)
+      if (registryInfo.registry === 'docker.io' && !imageUrl.includes('/') && !imageUrl.startsWith('gcr.io/')) {
+        console.log('Docker Hub failed, trying GCR fallback...');
+        
+        // Try GCR with the same image name
+        const gcrImage = `gcr.io/google-containers/${imageUrl}:${imageTag}`;
+        const gcrCommand = `skopeo copy ${archFlag} docker://${gcrImage} docker-archive:${fullPath}`;
+        
+        try {
+          await execAsync(gcrCommand);
+          console.log('Successfully downloaded from GCR fallback');
+          return true;
+        } catch (gcrError) {
+          console.error('GCR fallback also failed:', gcrError);
+          // Re-throw the original Docker error for proper error handling
+          throw dockerError;
+        }
+      } else {
+        // Re-throw the original error for other cases
+        throw dockerError;
+      }
+    }
   } catch (error) {
   } catch (error) {
     console.error('Failed to download image:', error);
     console.error('Failed to download image:', error);
+    
+    // Clean up any empty or partial file that might have been created
+    try {
+      const imageName = imageUrl.replace(/[^a-zA-Z0-9.-]/g, '_');
+      const fileName = `${imageName}_${imageTag}_${architecture}.tar`;
+      const fullPath = path.join(destPath, fileName);
+      
+      const stats = await fs.stat(fullPath);
+      if (stats.size === 0) {
+        await fs.unlink(fullPath);
+      }
+    } catch (cleanupError) {
+      // Ignore cleanup errors
+    }
+    
+    // Check for specific error messages and provide user-friendly responses
+    const errorMessage = error instanceof Error ? error.message : String(error);
+    
+    if (errorMessage.includes('unauthorized') || errorMessage.includes('invalid username/password')) {
+      throw new Error('Authentication failed. This image may require Docker Hub login or is from a private repository.');
+    } else if (errorMessage.includes('not found')) {
+      throw new Error('Image not found. Please check the image URL and tag.');
+    } else if (errorMessage.includes('manifest')) {
+      throw new Error('Failed to retrieve image manifest. The image may not exist or be accessible.');
+    } else if (errorMessage.includes('timeout')) {
+      throw new Error('Download timed out. Please try again or check your network connection.');
+    }
+    
     return false;
     return false;
   }
   }
 }
 }
@@ -429,6 +488,7 @@ export async function action({ request }: Route.ActionArgs) {
       const imageUrl = formData.get('imageUrl') as string;
       const imageUrl = formData.get('imageUrl') as string;
       const imageTag = formData.get('imageTag') as string;
       const imageTag = formData.get('imageTag') as string;
       const currentPath = formData.get('currentPath') as string;
       const currentPath = formData.get('currentPath') as string;
+      const architecture = formData.get('architecture') as string || 'amd64';
       
       
       if (!imageUrl || !imageUrl.trim()) {
       if (!imageUrl || !imageUrl.trim()) {
         return { success: false, error: "Image URL is required" };
         return { success: false, error: "Image URL is required" };
@@ -438,12 +498,18 @@ export async function action({ request }: Route.ActionArgs) {
         return { success: false, error: "Image tag is required" };
         return { success: false, error: "Image tag is required" };
       }
       }
       
       
-      const success = await downloadImage(imageUrl.trim(), imageTag.trim(), currentPath);
-      
-      if (success) {
-        return { success: true };
-      } else {
-        return { success: false, error: "Failed to download container image" };
+      try {
+        const success = await downloadImage(imageUrl.trim(), imageTag.trim(), currentPath, architecture);
+        
+        if (success) {
+          return { success: true };
+        } else {
+          return { success: false, error: "Failed to download container image" };
+        }
+      } catch (error) {
+        // Handle specific error messages from downloadImage function
+        const errorMessage = error instanceof Error ? error.message : String(error);
+        return { success: false, error: errorMessage };
       }
       }
     }
     }