mirror of
https://github.com/trycua/computer.git
synced 2026-01-05 21:09:58 -06:00
Handle cached images unknown
This commit is contained in:
14
.vscode/launch.json
vendored
14
.vscode/launch.json
vendored
@@ -190,6 +190,20 @@
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"images"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume images",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
|
||||
@@ -101,7 +101,7 @@ These images come with an SSH server pre-configured and auto-login enabled.
|
||||
|-------|------------|-------------|------|
|
||||
| `macos-sequoia-vanilla` | `latest`, `15.2` | macOS Sonoma 15.2 | 40GB |
|
||||
| `macos-sequoia-xcode` | `latest`, `15.2` | macOS Sonoma 15.2 with Xcode command line tools | 50GB |
|
||||
| `ubuntu-vanilla` | `latest`, `24.04.1` | [Ubuntu Server for ARM 24.04.1 LTS](https://ubuntu.com/download/server/arm) with Ubuntu Desktop | 20GB |
|
||||
| `ubuntu-noble-vanilla` | `latest`, `24.04.1` | [Ubuntu Server for ARM 24.04.1 LTS](https://ubuntu.com/download/server/arm) with Ubuntu Desktop | 20GB |
|
||||
|
||||
For additional disk space, resize the VM disk after pulling the image using the `lume set <name> --disk-size <size>` command.
|
||||
|
||||
|
||||
@@ -31,10 +31,16 @@ struct RepositoryTags: Codable {
|
||||
|
||||
struct CachedImage {
|
||||
let repository: String
|
||||
let tag: String
|
||||
let imageId: String
|
||||
let manifestId: String
|
||||
}
|
||||
|
||||
struct ImageMetadata: Codable {
|
||||
let image: String
|
||||
let manifestId: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
actor ProgressTracker {
|
||||
private var totalBytes: Int64 = 0
|
||||
private var downloadedBytes: Int64 = 0
|
||||
@@ -84,14 +90,15 @@ class ImageContainerRegistry: @unchecked Sendable {
|
||||
try? FileManager.default.createDirectory(at: orgDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private func getManifestIdentifier(_ manifest: Manifest) -> String {
|
||||
// Use config digest if available, otherwise create a hash from layers
|
||||
if let config = manifest.config {
|
||||
return config.digest.replacingOccurrences(of: ":", with: "_")
|
||||
}
|
||||
// If no config layer, create a hash from all layer digests
|
||||
let layerHash = manifest.layers.map { $0.digest }.joined(separator: "+")
|
||||
return layerHash.replacingOccurrences(of: ":", with: "_")
|
||||
private func getManifestIdentifier(_ manifest: Manifest, manifestDigest: String) -> String {
|
||||
// Use the manifest's own digest as the identifier
|
||||
return manifestDigest.replacingOccurrences(of: ":", with: "_")
|
||||
}
|
||||
|
||||
private func getShortImageId(_ digest: String) -> String {
|
||||
// Take first 12 characters of the digest after removing the "sha256:" prefix
|
||||
let id = digest.replacingOccurrences(of: "sha256:", with: "")
|
||||
return String(id.prefix(12))
|
||||
}
|
||||
|
||||
private func getImageCacheDirectory(manifestId: String) -> URL {
|
||||
@@ -179,6 +186,48 @@ class ImageContainerRegistry: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveImageMetadata(image: String, manifestId: String) throws {
|
||||
let metadataPath = getImageCacheDirectory(manifestId: manifestId).appendingPathComponent("metadata.json")
|
||||
let metadata = ImageMetadata(
|
||||
image: image,
|
||||
manifestId: manifestId,
|
||||
timestamp: Date()
|
||||
)
|
||||
try JSONEncoder().encode(metadata).write(to: metadataPath)
|
||||
}
|
||||
|
||||
private func cleanupOldVersions(currentManifestId: String, image: String) throws {
|
||||
Logger.info("Checking for old versions of image to clean up", metadata: [
|
||||
"image": image,
|
||||
"current_manifest_id": currentManifestId
|
||||
])
|
||||
|
||||
let orgDir = cacheDirectory.appendingPathComponent(organization)
|
||||
guard FileManager.default.fileExists(atPath: orgDir.path) else { return }
|
||||
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: orgDir.path)
|
||||
for item in contents {
|
||||
if item == currentManifestId { continue }
|
||||
|
||||
let itemPath = orgDir.appendingPathComponent(item)
|
||||
let metadataPath = itemPath.appendingPathComponent("metadata.json")
|
||||
|
||||
if let metadataData = try? Data(contentsOf: metadataPath),
|
||||
let metadata = try? JSONDecoder().decode(ImageMetadata.self, from: metadataData) {
|
||||
if metadata.image == image {
|
||||
try FileManager.default.removeItem(at: itemPath)
|
||||
Logger.info("Removed old version of image", metadata: [
|
||||
"image": image,
|
||||
"old_manifest_id": item
|
||||
])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
Logger.info("Skipping cleanup check for item without metadata", metadata: ["item": item])
|
||||
}
|
||||
}
|
||||
|
||||
func pull(image: String, name: String?) async throws {
|
||||
// Validate home directory
|
||||
let home = Home()
|
||||
@@ -202,31 +251,47 @@ class ImageContainerRegistry: @unchecked Sendable {
|
||||
|
||||
// Fetch manifest
|
||||
Logger.info("Fetching Image manifest")
|
||||
let manifest: Manifest = try await fetchManifest(
|
||||
let (manifest, manifestDigest): (Manifest, String) = try await fetchManifest(
|
||||
repository: "\(self.organization)/\(imageName)",
|
||||
tag: tag,
|
||||
token: token
|
||||
)
|
||||
|
||||
// Get manifest identifier
|
||||
let manifestId = getManifestIdentifier(manifest)
|
||||
// Get manifest identifier using the manifest's own digest
|
||||
let manifestId = getManifestIdentifier(manifest, manifestDigest: manifestDigest)
|
||||
|
||||
Logger.info("Pulling image", metadata: [
|
||||
"repository": imageName,
|
||||
"manifest_id": manifestId
|
||||
])
|
||||
|
||||
// Create VM directory
|
||||
try FileManager.default.createDirectory(at: URL(fileURLWithPath: vmDir.dir.path), withIntermediateDirectories: true)
|
||||
|
||||
// Check if we have a valid cached version
|
||||
Logger.info("Checking cache for manifest ID: \(manifestId)")
|
||||
if validateCache(manifest: manifest, manifestId: manifestId) {
|
||||
Logger.info("Using cached version of image")
|
||||
try await copyFromCache(manifest: manifest, manifestId: manifestId, to: URL(fileURLWithPath: vmDir.dir.path))
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up old versions of this repository before setting up new cache
|
||||
try cleanupOldVersions(currentManifestId: manifestId, image: imageName)
|
||||
|
||||
Logger.info("Cache miss or invalid cache, setting up new cache")
|
||||
// Setup new cache directory
|
||||
try setupImageCache(manifestId: manifestId)
|
||||
|
||||
// Save new manifest
|
||||
try saveManifest(manifest, manifestId: manifestId)
|
||||
|
||||
// Save image metadata
|
||||
try saveImageMetadata(
|
||||
image: imageName,
|
||||
manifestId: manifestId
|
||||
)
|
||||
|
||||
// Create temporary directory for new downloads
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
@@ -396,45 +461,6 @@ class ImageContainerRegistry: @unchecked Sendable {
|
||||
}
|
||||
|
||||
Logger.info("Download complete: Files extracted to \(vmDir.dir.path)")
|
||||
|
||||
// If this was a "latest" tag pull and we successfully downloaded and cached the new version,
|
||||
// clean up any old versions
|
||||
if tag.lowercased() == "latest" {
|
||||
let orgDir = cacheDirectory.appendingPathComponent(organization)
|
||||
if FileManager.default.fileExists(atPath: orgDir.path) {
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: orgDir.path)
|
||||
for item in contents {
|
||||
// Skip if it's the current manifest
|
||||
if item == manifestId { continue }
|
||||
|
||||
let itemPath = orgDir.appendingPathComponent(item)
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: itemPath.path, isDirectory: &isDirectory),
|
||||
isDirectory.boolValue else { continue }
|
||||
|
||||
// Check for manifest.json
|
||||
let manifestPath = itemPath.appendingPathComponent("manifest.json")
|
||||
guard let manifestData = try? Data(contentsOf: manifestPath),
|
||||
let oldManifest = try? JSONDecoder().decode(Manifest.self, from: manifestData),
|
||||
let config = oldManifest.config else { continue }
|
||||
let configPath = getCachedLayerPath(manifestId: item, digest: config.digest)
|
||||
guard let configData = try? Data(contentsOf: configPath),
|
||||
let configJson = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
||||
let labels = configJson["config"] as? [String: Any],
|
||||
let imageConfig = labels["Labels"] as? [String: String],
|
||||
let oldRepository = imageConfig["org.opencontainers.image.source"]?.components(separatedBy: "/").last else { continue }
|
||||
|
||||
// Only delete if it's from the same repository
|
||||
if oldRepository == imageName {
|
||||
try FileManager.default.removeItem(at: itemPath)
|
||||
Logger.info("Removed outdated cached version", metadata: [
|
||||
"old_manifest_id": item,
|
||||
"repository": imageName
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func copyFromCache(manifest: Manifest, manifestId: String, to destination: URL) async throws {
|
||||
@@ -524,18 +550,20 @@ class ImageContainerRegistry: @unchecked Sendable {
|
||||
return token
|
||||
}
|
||||
|
||||
private func fetchManifest(repository: String, tag: String, token: String) async throws -> Manifest {
|
||||
private func fetchManifest(repository: String, tag: String, token: String) async throws -> (Manifest, String) {
|
||||
var request = URLRequest(url: URL(string: "https://\(self.registry)/v2/\(repository)/manifests/\(tag)")!)
|
||||
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.addValue("application/vnd.oci.image.manifest.v1+json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
httpResponse.statusCode == 200,
|
||||
let digest = httpResponse.value(forHTTPHeaderField: "Docker-Content-Digest") else {
|
||||
throw PullError.manifestFetchFailed
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(Manifest.self, from: data)
|
||||
let manifest = try JSONDecoder().decode(Manifest.self, from: data)
|
||||
return (manifest, digest)
|
||||
}
|
||||
|
||||
private func downloadLayer(
|
||||
@@ -679,66 +707,69 @@ class ImageContainerRegistry: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func getImages() async throws -> [CachedImage] {
|
||||
Logger.info("Scanning for cached images in \(cacheDirectory.path)")
|
||||
var images: [CachedImage] = []
|
||||
let orgDir = cacheDirectory.appendingPathComponent(organization)
|
||||
|
||||
if FileManager.default.fileExists(atPath: orgDir.path) {
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: orgDir.path)
|
||||
Logger.info("Found \(contents.count) items in cache directory")
|
||||
|
||||
for item in contents {
|
||||
let itemPath = orgDir.appendingPathComponent(item)
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
// Check if it's a directory
|
||||
guard FileManager.default.fileExists(atPath: itemPath.path, isDirectory: &isDirectory),
|
||||
isDirectory.boolValue else { continue }
|
||||
|
||||
// Check for manifest.json
|
||||
// First try to read metadata file
|
||||
let metadataPath = itemPath.appendingPathComponent("metadata.json")
|
||||
if let metadataData = try? Data(contentsOf: metadataPath),
|
||||
let metadata = try? JSONDecoder().decode(ImageMetadata.self, from: metadataData) {
|
||||
Logger.info("Found metadata for image", metadata: [
|
||||
"image": metadata.image,
|
||||
"manifest_id": metadata.manifestId
|
||||
])
|
||||
images.append(CachedImage(
|
||||
repository: metadata.image,
|
||||
imageId: String(metadata.manifestId.prefix(12)),
|
||||
manifestId: metadata.manifestId
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
// Fallback to checking manifest if metadata doesn't exist
|
||||
Logger.info("No metadata found for \(item), checking manifest")
|
||||
let manifestPath = itemPath.appendingPathComponent("manifest.json")
|
||||
guard FileManager.default.fileExists(atPath: manifestPath.path),
|
||||
let manifestData = try? Data(contentsOf: manifestPath),
|
||||
let manifest = try? JSONDecoder().decode(Manifest.self, from: manifestData) else { continue }
|
||||
let manifest = try? JSONDecoder().decode(Manifest.self, from: manifestData) else {
|
||||
Logger.info("No valid manifest found for \(item)")
|
||||
continue
|
||||
}
|
||||
|
||||
// The directory name is now just the manifest ID
|
||||
let manifestId = item
|
||||
|
||||
// Verify the manifest ID matches
|
||||
let currentManifestId = getManifestIdentifier(manifest)
|
||||
let currentManifestId = getManifestIdentifier(manifest, manifestDigest: "")
|
||||
Logger.info("Manifest check", metadata: [
|
||||
"item": item,
|
||||
"current_manifest_id": currentManifestId,
|
||||
"matches": "\(currentManifestId == manifestId)"
|
||||
])
|
||||
if currentManifestId == manifestId {
|
||||
// Add the image with just the manifest ID for now
|
||||
images.append(CachedImage(
|
||||
repository: "unknown",
|
||||
tag: "unknown",
|
||||
manifestId: manifestId
|
||||
))
|
||||
// Skip if we can't determine the repository name
|
||||
// This should be rare since we now save metadata during pull
|
||||
Logger.info("Skipping image without metadata: \(item)")
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.info("Cache directory does not exist")
|
||||
}
|
||||
|
||||
// For each cached image, try to find its repository and tag by checking the config
|
||||
for i in 0..<images.count {
|
||||
let manifestId = images[i].manifestId
|
||||
let manifestPath = getCachedManifestPath(manifestId: manifestId)
|
||||
|
||||
if let manifestData = try? Data(contentsOf: manifestPath),
|
||||
let manifest = try? JSONDecoder().decode(Manifest.self, from: manifestData),
|
||||
let config = manifest.config,
|
||||
let configData = try? Data(contentsOf: getCachedLayerPath(manifestId: manifestId, digest: config.digest)),
|
||||
let configJson = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
||||
let labels = configJson["config"] as? [String: Any],
|
||||
let imageConfig = labels["Labels"] as? [String: String],
|
||||
let repository = imageConfig["org.opencontainers.image.source"]?.components(separatedBy: "/").last,
|
||||
let tag = imageConfig["org.opencontainers.image.version"] {
|
||||
|
||||
// Found repository and tag information in the config
|
||||
images[i] = CachedImage(
|
||||
repository: repository,
|
||||
tag: tag,
|
||||
manifestId: manifestId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return images.sorted { $0.repository == $1.repository ? $0.tag < $1.tag : $0.repository < $1.repository }
|
||||
Logger.info("Found \(images.count) cached images")
|
||||
return images.sorted { $0.repository == $1.repository ? $0.imageId < $1.imageId : $0.repository < $1.repository }
|
||||
}
|
||||
|
||||
private func listRemoteImageTags(repository: String) async throws -> [String] {
|
||||
|
||||
@@ -9,7 +9,7 @@ struct ImagesPrinter {
|
||||
|
||||
private static let columns: [Column] = [
|
||||
Column(header: "name", width: 28) { $0.split(separator: ":").first.map(String.init) ?? $0 },
|
||||
Column(header: "tag", width: 16) { $0.split(separator: ":").last.map(String.init) ?? "-" }
|
||||
Column(header: "image_id", width: 16) { $0.split(separator: ":").last.map(String.init) ?? "-" }
|
||||
]
|
||||
|
||||
static func print(images: [String]) {
|
||||
|
||||
@@ -351,17 +351,12 @@ final class LumeController {
|
||||
|
||||
public struct ImageInfo: Codable {
|
||||
public let repository: String
|
||||
public let tag: String
|
||||
public let manifestId: String
|
||||
|
||||
public var fullName: String {
|
||||
return "\(repository):\(tag)"
|
||||
}
|
||||
public let imageId: String // This will be the shortened manifest ID
|
||||
}
|
||||
|
||||
public struct ImageList: Codable {
|
||||
public let local: [ImageInfo]
|
||||
public let remote: [String]
|
||||
public let remote: [String] // Keep this for future remote registry support
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -372,10 +367,13 @@ final class LumeController {
|
||||
let cachedImages = try await imageContainerRegistry.getImages()
|
||||
|
||||
let imageInfos = cachedImages.map { image in
|
||||
ImageInfo(repository: image.repository, tag: image.tag, manifestId: image.manifestId)
|
||||
ImageInfo(
|
||||
repository: image.repository,
|
||||
imageId: String(image.manifestId.prefix(12))
|
||||
)
|
||||
}
|
||||
|
||||
ImagesPrinter.print(images: imageInfos.map { $0.fullName })
|
||||
ImagesPrinter.print(images: imageInfos.map { "\($0.repository):\($0.imageId)" })
|
||||
return ImageList(local: imageInfos, remote: [])
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ struct Lume: AsyncParsableCommand {
|
||||
// MARK: - Version Management
|
||||
extension Lume {
|
||||
enum Version {
|
||||
static let current = "0.1.0"
|
||||
static let current: String = "0.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,6 @@ extension Server {
|
||||
}
|
||||
|
||||
func handleGetImages(_ request: HTTPRequest) async throws -> HTTPResponse {
|
||||
// Parse query parameters from URL path and query string
|
||||
let pathAndQuery = request.path.split(separator: "?", maxSplits: 1)
|
||||
let queryParams = pathAndQuery.count > 1 ? pathAndQuery[1]
|
||||
.split(separator: "&")
|
||||
@@ -272,12 +271,18 @@ extension Server {
|
||||
|
||||
do {
|
||||
let vmController = LumeController()
|
||||
let images = try await vmController.getImages(organization: organization)
|
||||
let imageList = try await vmController.getImages(organization: organization)
|
||||
|
||||
// Create a response format that matches the CLI output
|
||||
let response = imageList.local.map { [
|
||||
"repository": $0.repository,
|
||||
"imageId": $0.imageId
|
||||
] }
|
||||
|
||||
return HTTPResponse(
|
||||
statusCode: .ok,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(images)
|
||||
body: try JSONEncoder().encode(response)
|
||||
)
|
||||
} catch {
|
||||
return HTTPResponse(
|
||||
|
||||
Reference in New Issue
Block a user