Better support for Talos ISO downloads.

This commit is contained in:
2025-10-12 20:15:43 +00:00
parent 3a8488eaff
commit 1d11996cd6
7 changed files with 913 additions and 53 deletions

View File

@@ -7,6 +7,7 @@
## Principles ## Principles
- The API enables all of the functionaly needed by the CLI and the webapp. These clients should conform to the API. The API should not be designed around the needs of the CLI or webapp.
- A wild cloud instance is primarily data (YAML files for config, secrets, and manifests). - A wild cloud instance is primarily data (YAML files for config, secrets, and manifests).
- Because a wild cloud instance is primarily data, a wild cloud instance can be managed by non-technical users through the webapp or by technical users by SSHing into the device (e.g. VSCode Remote SSH). - Because a wild cloud instance is primarily data, a wild cloud instance can be managed by non-technical users through the webapp or by technical users by SSHing into the device (e.g. VSCode Remote SSH).
- Like v.PoC, we should only use gomplate templates for distinguishing between cloud instances. However, **within** a cloud instance, there should be no templating. The templates are compiled when being copied into the instances. This allows transparency and simple management by the user. - Like v.PoC, we should only use gomplate templates for distinguishing between cloud instances. However, **within** a cloud instance, there should be no templating. The templates are compiled when being copied into the instances. This allows transparency and simple management by the user.

View File

@@ -61,28 +61,27 @@ func NewAPI(dataDir, appsDir string) (*API, error) {
}, nil }, nil
} }
// RegisterRoutes registers all API routes (Phase 1 + Phase 2)
func (api *API) RegisterRoutes(r *mux.Router) { func (api *API) RegisterRoutes(r *mux.Router) {
// Phase 1: Instance management // Instance management
r.HandleFunc("/api/v1/instances", api.CreateInstance).Methods("POST") r.HandleFunc("/api/v1/instances", api.CreateInstance).Methods("POST")
r.HandleFunc("/api/v1/instances", api.ListInstances).Methods("GET") r.HandleFunc("/api/v1/instances", api.ListInstances).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}", api.GetInstance).Methods("GET") r.HandleFunc("/api/v1/instances/{name}", api.GetInstance).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}", api.DeleteInstance).Methods("DELETE") r.HandleFunc("/api/v1/instances/{name}", api.DeleteInstance).Methods("DELETE")
// Phase 1: Config management // Config management
r.HandleFunc("/api/v1/instances/{name}/config", api.GetConfig).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/config", api.GetConfig).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}/config", api.UpdateConfig).Methods("PUT") r.HandleFunc("/api/v1/instances/{name}/config", api.UpdateConfig).Methods("PUT")
r.HandleFunc("/api/v1/instances/{name}/config", api.ConfigUpdateBatch).Methods("PATCH") r.HandleFunc("/api/v1/instances/{name}/config", api.ConfigUpdateBatch).Methods("PATCH")
// Phase 1: Secrets management // Secrets management
r.HandleFunc("/api/v1/instances/{name}/secrets", api.GetSecrets).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/secrets", api.GetSecrets).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}/secrets", api.UpdateSecrets).Methods("PUT") r.HandleFunc("/api/v1/instances/{name}/secrets", api.UpdateSecrets).Methods("PUT")
// Phase 1: Context management // Context management
r.HandleFunc("/api/v1/context", api.GetContext).Methods("GET") r.HandleFunc("/api/v1/context", api.GetContext).Methods("GET")
r.HandleFunc("/api/v1/context", api.SetContext).Methods("POST") r.HandleFunc("/api/v1/context", api.SetContext).Methods("POST")
// Phase 2: Node management // Node management
r.HandleFunc("/api/v1/instances/{name}/nodes/discover", api.NodeDiscover).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/nodes/discover", api.NodeDiscover).Methods("POST")
r.HandleFunc("/api/v1/instances/{name}/nodes/detect", api.NodeDetect).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/nodes/detect", api.NodeDetect).Methods("POST")
r.HandleFunc("/api/v1/instances/{name}/discovery", api.NodeDiscoveryStatus).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/discovery", api.NodeDiscoveryStatus).Methods("GET")
@@ -95,19 +94,25 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/instances/{name}/nodes/{node}/apply", api.NodeApply).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/nodes/{node}/apply", api.NodeApply).Methods("POST")
r.HandleFunc("/api/v1/instances/{name}/nodes/{node}", api.NodeDelete).Methods("DELETE") r.HandleFunc("/api/v1/instances/{name}/nodes/{node}", api.NodeDelete).Methods("DELETE")
// Phase 2: PXE asset management // Asset management
r.HandleFunc("/api/v1/instances/{name}/pxe/assets", api.PXEListAssets).Methods("GET") r.HandleFunc("/api/v1/assets", api.AssetsListSchematics).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}/pxe/assets/download", api.PXEDownloadAsset).Methods("POST") r.HandleFunc("/api/v1/assets/{schematicId}", api.AssetsGetSchematic).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}/pxe/assets/{type}", api.PXEGetAsset).Methods("GET") r.HandleFunc("/api/v1/assets/{schematicId}/download", api.AssetsDownload).Methods("POST")
r.HandleFunc("/api/v1/instances/{name}/pxe/assets/{type}", api.PXEDeleteAsset).Methods("DELETE") r.HandleFunc("/api/v1/assets/{schematicId}/pxe/{assetType}", api.AssetsServePXE).Methods("GET")
r.HandleFunc("/api/v1/assets/{schematicId}/status", api.AssetsGetStatus).Methods("GET")
r.HandleFunc("/api/v1/assets/{schematicId}", api.AssetsDeleteSchematic).Methods("DELETE")
// Phase 2: Operations // Instance-schematic relationship
r.HandleFunc("/api/v1/instances/{name}/schematic", api.SchematicGetInstanceSchematic).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}/schematic", api.SchematicUpdateInstanceSchematic).Methods("PUT")
// Operations
r.HandleFunc("/api/v1/instances/{name}/operations", api.OperationList).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/operations", api.OperationList).Methods("GET")
r.HandleFunc("/api/v1/operations/{id}", api.OperationGet).Methods("GET") r.HandleFunc("/api/v1/operations/{id}", api.OperationGet).Methods("GET")
r.HandleFunc("/api/v1/operations/{id}/stream", api.OperationStream).Methods("GET") r.HandleFunc("/api/v1/operations/{id}/stream", api.OperationStream).Methods("GET")
r.HandleFunc("/api/v1/operations/{id}/cancel", api.OperationCancel).Methods("POST") r.HandleFunc("/api/v1/operations/{id}/cancel", api.OperationCancel).Methods("POST")
// Phase 3: Cluster operations // Cluster operations
r.HandleFunc("/api/v1/instances/{name}/cluster/config/generate", api.ClusterGenerateConfig).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/cluster/config/generate", api.ClusterGenerateConfig).Methods("POST")
r.HandleFunc("/api/v1/instances/{name}/cluster/bootstrap", api.ClusterBootstrap).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/cluster/bootstrap", api.ClusterBootstrap).Methods("POST")
r.HandleFunc("/api/v1/instances/{name}/cluster/endpoints", api.ClusterConfigureEndpoints).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/cluster/endpoints", api.ClusterConfigureEndpoints).Methods("POST")
@@ -118,7 +123,7 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/instances/{name}/cluster/talosconfig", api.ClusterGetTalosconfig).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/cluster/talosconfig", api.ClusterGetTalosconfig).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}/cluster/reset", api.ClusterReset).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/cluster/reset", api.ClusterReset).Methods("POST")
// Phase 4: Services // Services
r.HandleFunc("/api/v1/instances/{name}/services", api.ServicesList).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/services", api.ServicesList).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}/services", api.ServicesInstall).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/services", api.ServicesInstall).Methods("POST")
r.HandleFunc("/api/v1/instances/{name}/services/install-all", api.ServicesInstallAll).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/services/install-all", api.ServicesInstallAll).Methods("POST")
@@ -134,7 +139,7 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/instances/{name}/services/{service}/compile", api.ServicesCompile).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/services/{service}/compile", api.ServicesCompile).Methods("POST")
r.HandleFunc("/api/v1/instances/{name}/services/{service}/deploy", api.ServicesDeploy).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/services/{service}/deploy", api.ServicesDeploy).Methods("POST")
// Phase 4: Apps // Apps
r.HandleFunc("/api/v1/apps", api.AppsListAvailable).Methods("GET") r.HandleFunc("/api/v1/apps", api.AppsListAvailable).Methods("GET")
r.HandleFunc("/api/v1/apps/{app}", api.AppsGetAvailable).Methods("GET") r.HandleFunc("/api/v1/apps/{app}", api.AppsGetAvailable).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}/apps", api.AppsListDeployed).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/apps", api.AppsListDeployed).Methods("GET")
@@ -143,12 +148,12 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/instances/{name}/apps/{app}", api.AppsDelete).Methods("DELETE") r.HandleFunc("/api/v1/instances/{name}/apps/{app}", api.AppsDelete).Methods("DELETE")
r.HandleFunc("/api/v1/instances/{name}/apps/{app}/status", api.AppsGetStatus).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/status", api.AppsGetStatus).Methods("GET")
// Phase 5: Backup & Restore // Backup & Restore
r.HandleFunc("/api/v1/instances/{name}/apps/{app}/backup", api.BackupAppStart).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/backup", api.BackupAppStart).Methods("POST")
r.HandleFunc("/api/v1/instances/{name}/apps/{app}/backup", api.BackupAppList).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/backup", api.BackupAppList).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}/apps/{app}/restore", api.BackupAppRestore).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/restore", api.BackupAppRestore).Methods("POST")
// Phase 5: Utilities // Utilities
r.HandleFunc("/api/v1/utilities/health", api.UtilitiesHealth).Methods("GET") r.HandleFunc("/api/v1/utilities/health", api.UtilitiesHealth).Methods("GET")
r.HandleFunc("/api/v1/instances/{name}/utilities/health", api.InstanceUtilitiesHealth).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/utilities/health", api.InstanceUtilitiesHealth).Methods("GET")
r.HandleFunc("/api/v1/utilities/dashboard/token", api.UtilitiesDashboardToken).Methods("GET") r.HandleFunc("/api/v1/utilities/dashboard/token", api.UtilitiesDashboardToken).Methods("GET")

View File

@@ -0,0 +1,172 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"os"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/daemon/internal/assets"
)
// AssetsListSchematics lists all available schematics
func (api *API) AssetsListSchematics(w http.ResponseWriter, r *http.Request) {
assetsMgr := assets.NewManager(api.dataDir)
schematics, err := assetsMgr.ListSchematics()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list schematics: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"schematics": schematics,
})
}
// AssetsGetSchematic returns details for a specific schematic
func (api *API) AssetsGetSchematic(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
schematicID := vars["schematicId"]
assetsMgr := assets.NewManager(api.dataDir)
schematic, err := assetsMgr.GetSchematic(schematicID)
if err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Schematic not found: %v", err))
return
}
respondJSON(w, http.StatusOK, schematic)
}
// AssetsDownload downloads assets for a schematic
func (api *API) AssetsDownload(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
schematicID := vars["schematicId"]
// Parse request body
var req struct {
Version string `json:"version"`
Platform string `json:"platform,omitempty"`
AssetTypes []string `json:"asset_types,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Version == "" {
respondError(w, http.StatusBadRequest, "version is required")
return
}
// Default platform to amd64 if not specified
if req.Platform == "" {
req.Platform = "amd64"
}
// Download assets
assetsMgr := assets.NewManager(api.dataDir)
if err := assetsMgr.DownloadAssets(schematicID, req.Version, req.Platform, req.AssetTypes); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to download assets: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Assets downloaded successfully",
"schematic_id": schematicID,
"version": req.Version,
"platform": req.Platform,
})
}
// AssetsServePXE serves a PXE asset file
func (api *API) AssetsServePXE(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
schematicID := vars["schematicId"]
assetType := vars["assetType"]
assetsMgr := assets.NewManager(api.dataDir)
// Get asset path
assetPath, err := assetsMgr.GetAssetPath(schematicID, assetType)
if err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Asset not found: %v", err))
return
}
// Open file
file, err := os.Open(assetPath)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to open asset: %v", err))
return
}
defer file.Close()
// Get file info for size
info, err := file.Stat()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to stat asset: %v", err))
return
}
// Set appropriate content type
var contentType string
switch assetType {
case "kernel":
contentType = "application/octet-stream"
case "initramfs":
contentType = "application/x-xz"
case "iso":
contentType = "application/x-iso9660-image"
default:
contentType = "application/octet-stream"
}
// Set headers
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
// Set Content-Disposition to suggest filename for download
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", info.Name()))
// Serve file
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
}
// AssetsGetStatus returns download status for a schematic
func (api *API) AssetsGetStatus(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
schematicID := vars["schematicId"]
assetsMgr := assets.NewManager(api.dataDir)
status, err := assetsMgr.GetAssetStatus(schematicID)
if err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Schematic not found: %v", err))
return
}
respondJSON(w, http.StatusOK, status)
}
// AssetsDeleteSchematic deletes a schematic and all its assets
func (api *API) AssetsDeleteSchematic(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
schematicID := vars["schematicId"]
assetsMgr := assets.NewManager(api.dataDir)
if err := assetsMgr.DeleteSchematic(schematicID); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete schematic: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "Schematic deleted successfully",
"schematic_id": schematicID,
})
}

View File

@@ -22,8 +22,9 @@ func (api *API) NodeDiscover(w http.ResponseWriter, r *http.Request) {
return return
} }
// Parse request body // Parse request body - support both subnet and ip_list formats
var req struct { var req struct {
Subnet string `json:"subnet"`
IPList []string `json:"ip_list"` IPList []string `json:"ip_list"`
} }
@@ -32,14 +33,21 @@ func (api *API) NodeDiscover(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(req.IPList) == 0 { // If subnet provided, use it as a single "IP" for discovery
respondError(w, http.StatusBadRequest, "ip_list is required") // The discovery manager will scan this subnet
var ipList []string
if req.Subnet != "" {
ipList = []string{req.Subnet}
} else if len(req.IPList) > 0 {
ipList = req.IPList
} else {
respondError(w, http.StatusBadRequest, "subnet or ip_list is required")
return return
} }
// Start discovery // Start discovery
discoveryMgr := discovery.NewManager(api.dataDir, instanceName) discoveryMgr := discovery.NewManager(api.dataDir, instanceName)
if err := discoveryMgr.StartDiscovery(instanceName, req.IPList); err != nil { if err := discoveryMgr.StartDiscovery(instanceName, ipList); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to start discovery: %v", err)) respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to start discovery: %v", err))
return return
} }

View File

@@ -3,42 +3,72 @@ package v1
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/daemon/internal/assets"
"github.com/wild-cloud/wild-central/daemon/internal/pxe" "github.com/wild-cloud/wild-central/daemon/internal/pxe"
) )
// PXEListAssets lists all PXE assets for an instance // PXEListAssets lists all PXE assets for an instance
// DEPRECATED: This endpoint is deprecated. Use GET /api/v1/assets/{schematicId} instead.
func (api *API) PXEListAssets(w http.ResponseWriter, r *http.Request) { func (api *API) PXEListAssets(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
instanceName := vars["name"] instanceName := vars["name"]
// Add deprecation warning header
w.Header().Set("X-Deprecated", "This endpoint is deprecated. Use GET /api/v1/assets/{schematicId} instead.")
log.Printf("Warning: Deprecated endpoint /api/v1/instances/%s/pxe/assets called", instanceName)
// Validate instance exists // Validate instance exists
if err := api.instance.ValidateInstance(instanceName); err != nil { if err := api.instance.ValidateInstance(instanceName); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err))
return return
} }
// List assets // Get schematic ID from instance config
pxeMgr := pxe.NewManager(api.dataDir) configPath := api.instance.GetInstanceConfigPath(instanceName)
assets, err := pxeMgr.ListAssets(instanceName) schematicID, err := api.config.GetConfigValue(configPath, "cluster.nodes.talos.schematicId")
if err != nil || schematicID == "" || schematicID == "null" {
// Fall back to old PXE manager if no schematic configured
pxeMgr := pxe.NewManager(api.dataDir)
assets, err := pxeMgr.ListAssets(instanceName)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list assets: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"assets": assets,
})
return
}
// Proxy to new asset system
assetsMgr := assets.NewManager(api.dataDir)
schematic, err := assetsMgr.GetSchematic(schematicID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list assets: %v", err)) respondError(w, http.StatusNotFound, fmt.Sprintf("Schematic not found: %v", err))
return return
} }
respondJSON(w, http.StatusOK, map[string]interface{}{ respondJSON(w, http.StatusOK, map[string]interface{}{
"assets": assets, "assets": schematic.Assets,
}) })
} }
// PXEDownloadAsset downloads a PXE asset // PXEDownloadAsset downloads a PXE asset
// DEPRECATED: This endpoint is deprecated. Use POST /api/v1/assets/{schematicId}/download instead.
func (api *API) PXEDownloadAsset(w http.ResponseWriter, r *http.Request) { func (api *API) PXEDownloadAsset(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
instanceName := vars["name"] instanceName := vars["name"]
// Add deprecation warning header
w.Header().Set("X-Deprecated", "This endpoint is deprecated. Use POST /api/v1/assets/{schematicId}/download instead.")
log.Printf("Warning: Deprecated endpoint /api/v1/instances/%s/pxe/assets/download called", instanceName)
// Validate instance exists // Validate instance exists
if err := api.instance.ValidateInstance(instanceName); err != nil { if err := api.instance.ValidateInstance(instanceName); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err))
@@ -62,80 +92,154 @@ func (api *API) PXEDownloadAsset(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.URL == "" { // Get schematic ID from instance config
respondError(w, http.StatusBadRequest, "url is required") configPath := api.instance.GetInstanceConfigPath(instanceName)
schematicID, err := api.config.GetConfigValue(configPath, "cluster.nodes.talos.schematicId")
// If no schematic configured or URL provided (old behavior), fall back to old PXE manager
if (err != nil || schematicID == "" || schematicID == "null") || req.URL != "" {
if req.URL == "" {
respondError(w, http.StatusBadRequest, "url is required when schematic is not configured")
return
}
// Download asset using old PXE manager
pxeMgr := pxe.NewManager(api.dataDir)
if err := pxeMgr.DownloadAsset(instanceName, req.AssetType, req.Version, req.URL); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to download asset: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "Asset downloaded successfully",
"asset_type": req.AssetType,
"version": req.Version,
})
return return
} }
// Download asset // Proxy to new asset system
pxeMgr := pxe.NewManager(api.dataDir) if req.Version == "" {
if err := pxeMgr.DownloadAsset(instanceName, req.AssetType, req.Version, req.URL); err != nil { respondError(w, http.StatusBadRequest, "version is required")
return
}
assetsMgr := assets.NewManager(api.dataDir)
assetTypes := []string{req.AssetType}
platform := "amd64" // Default platform for backward compatibility
if err := assetsMgr.DownloadAssets(schematicID, req.Version, platform, assetTypes); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to download asset: %v", err)) respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to download asset: %v", err))
return return
} }
respondJSON(w, http.StatusOK, map[string]string{ respondJSON(w, http.StatusOK, map[string]string{
"message": "Asset downloaded successfully", "message": "Asset downloaded successfully",
"asset_type": req.AssetType, "asset_type": req.AssetType,
"version": req.Version, "version": req.Version,
"schematic_id": schematicID,
}) })
} }
// PXEGetAsset returns information about a specific asset // PXEGetAsset returns information about a specific asset
// DEPRECATED: This endpoint is deprecated. Use GET /api/v1/assets/{schematicId}/pxe/{assetType} instead.
func (api *API) PXEGetAsset(w http.ResponseWriter, r *http.Request) { func (api *API) PXEGetAsset(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
instanceName := vars["name"] instanceName := vars["name"]
assetType := vars["type"] assetType := vars["type"]
// Add deprecation warning header
w.Header().Set("X-Deprecated", "This endpoint is deprecated. Use GET /api/v1/assets/{schematicId}/pxe/{assetType} instead.")
log.Printf("Warning: Deprecated endpoint /api/v1/instances/%s/pxe/assets/%s called", instanceName, assetType)
// Validate instance exists // Validate instance exists
if err := api.instance.ValidateInstance(instanceName); err != nil { if err := api.instance.ValidateInstance(instanceName); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err))
return return
} }
// Get asset path // Get schematic ID from instance config
pxeMgr := pxe.NewManager(api.dataDir) configPath := api.instance.GetInstanceConfigPath(instanceName)
assetPath, err := pxeMgr.GetAssetPath(instanceName, assetType) schematicID, err := api.config.GetConfigValue(configPath, "cluster.nodes.talos.schematicId")
if err != nil || schematicID == "" || schematicID == "null" {
// Fall back to old PXE manager if no schematic configured
pxeMgr := pxe.NewManager(api.dataDir)
assetPath, err := pxeMgr.GetAssetPath(instanceName, assetType)
if err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Asset not found: %v", err))
return
}
// Verify asset
valid, err := pxeMgr.VerifyAsset(instanceName, assetType)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify asset: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"type": assetType,
"path": assetPath,
"valid": valid,
})
return
}
// Proxy to new asset system - serve the file directly
assetsMgr := assets.NewManager(api.dataDir)
assetPath, err := assetsMgr.GetAssetPath(schematicID, assetType)
if err != nil { if err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Asset not found: %v", err)) respondError(w, http.StatusNotFound, fmt.Sprintf("Asset not found: %v", err))
return return
} }
// Verify asset
valid, err := pxeMgr.VerifyAsset(instanceName, assetType)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify asset: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{ respondJSON(w, http.StatusOK, map[string]interface{}{
"type": assetType, "type": assetType,
"path": assetPath, "path": assetPath,
"valid": valid, "valid": true,
"schematic_id": schematicID,
}) })
} }
// PXEDeleteAsset deletes a PXE asset // PXEDeleteAsset deletes a PXE asset
// DEPRECATED: This endpoint is deprecated. Use DELETE /api/v1/assets/{schematicId} instead.
func (api *API) PXEDeleteAsset(w http.ResponseWriter, r *http.Request) { func (api *API) PXEDeleteAsset(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
instanceName := vars["name"] instanceName := vars["name"]
assetType := vars["type"] assetType := vars["type"]
// Add deprecation warning header
w.Header().Set("X-Deprecated", "This endpoint is deprecated. Use DELETE /api/v1/assets/{schematicId} instead.")
log.Printf("Warning: Deprecated endpoint DELETE /api/v1/instances/%s/pxe/assets/%s called", instanceName, assetType)
// Validate instance exists // Validate instance exists
if err := api.instance.ValidateInstance(instanceName); err != nil { if err := api.instance.ValidateInstance(instanceName); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err))
return return
} }
// Delete asset // Get schematic ID from instance config
pxeMgr := pxe.NewManager(api.dataDir) configPath := api.instance.GetInstanceConfigPath(instanceName)
if err := pxeMgr.DeleteAsset(instanceName, assetType); err != nil { schematicID, err := api.config.GetConfigValue(configPath, "cluster.nodes.talos.schematicId")
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete asset: %v", err)) if err != nil || schematicID == "" || schematicID == "null" {
// Fall back to old PXE manager if no schematic configured
pxeMgr := pxe.NewManager(api.dataDir)
if err := pxeMgr.DeleteAsset(instanceName, assetType); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete asset: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "Asset deleted successfully",
"type": assetType,
})
return return
} }
// Note: In the new system, we don't delete individual assets, only entire schematics
// For backward compatibility, we'll just report success without doing anything
respondJSON(w, http.StatusOK, map[string]string{ respondJSON(w, http.StatusOK, map[string]string{
"message": "Asset deleted successfully", "message": "Individual asset deletion not supported in schematic mode. Use DELETE /api/v1/assets/{schematicId} to delete all assets.",
"type": assetType, "type": assetType,
"schematic_id": schematicID,
}) })
} }

View File

@@ -0,0 +1,122 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/daemon/internal/assets"
)
// SchematicGetInstanceSchematic returns the schematic configuration for an instance
func (api *API) SchematicGetInstanceSchematic(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
instanceName := vars["name"]
// Validate instance exists
if err := api.instance.ValidateInstance(instanceName); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err))
return
}
configPath := api.instance.GetInstanceConfigPath(instanceName)
// Get schematic ID from config
schematicID, err := api.config.GetConfigValue(configPath, "cluster.nodes.talos.schematicId")
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get schematic ID: %v", err))
return
}
// Get version from config
version, err := api.config.GetConfigValue(configPath, "cluster.nodes.talos.version")
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get version: %v", err))
return
}
// If schematic is configured, get asset status
var assetStatus interface{}
if schematicID != "" && schematicID != "null" {
assetsMgr := assets.NewManager(api.dataDir)
status, err := assetsMgr.GetAssetStatus(schematicID)
if err == nil {
assetStatus = status
}
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"schematic_id": schematicID,
"version": version,
"assets": assetStatus,
})
}
// SchematicUpdateInstanceSchematic updates the schematic configuration for an instance
func (api *API) SchematicUpdateInstanceSchematic(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
instanceName := vars["name"]
// Validate instance exists
if err := api.instance.ValidateInstance(instanceName); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err))
return
}
// Parse request body
var req struct {
SchematicID string `json:"schematic_id"`
Version string `json:"version"`
Download bool `json:"download,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.SchematicID == "" {
respondError(w, http.StatusBadRequest, "schematic_id is required")
return
}
if req.Version == "" {
respondError(w, http.StatusBadRequest, "version is required")
return
}
configPath := api.instance.GetInstanceConfigPath(instanceName)
// Update schematic ID in config
if err := api.config.SetConfigValue(configPath, "cluster.nodes.talos.schematicId", req.SchematicID); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to set schematic ID: %v", err))
return
}
// Update version in config
if err := api.config.SetConfigValue(configPath, "cluster.nodes.talos.version", req.Version); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to set version: %v", err))
return
}
response := map[string]interface{}{
"message": "Schematic configuration updated successfully",
"schematic_id": req.SchematicID,
"version": req.Version,
}
// Optionally download assets
if req.Download {
assetsMgr := assets.NewManager(api.dataDir)
platform := "amd64" // Default platform
if err := assetsMgr.DownloadAssets(req.SchematicID, req.Version, platform, nil); err != nil {
response["download_warning"] = fmt.Sprintf("Failed to download assets: %v", err)
} else {
response["download_status"] = "Assets downloaded successfully"
}
}
respondJSON(w, http.StatusOK, response)
}

448
internal/assets/assets.go Normal file
View File

@@ -0,0 +1,448 @@
package assets
import (
"crypto/sha256"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/wild-cloud/wild-central/daemon/internal/storage"
)
// Manager handles centralized Talos asset management
type Manager struct {
dataDir string
}
// NewManager creates a new asset manager
func NewManager(dataDir string) *Manager {
return &Manager{
dataDir: dataDir,
}
}
// Asset represents a Talos boot asset
type Asset struct {
Type string `json:"type"` // kernel, initramfs, iso
Path string `json:"path"` // Full path to asset file
Size int64 `json:"size"` // File size in bytes
SHA256 string `json:"sha256"` // SHA256 hash
Downloaded bool `json:"downloaded"` // Whether asset exists
}
// Schematic represents a Talos schematic and its assets
type Schematic struct {
SchematicID string `json:"schematic_id"`
Version string `json:"version"`
Path string `json:"path"`
Assets []Asset `json:"assets"`
}
// AssetStatus represents download status for a schematic
type AssetStatus struct {
SchematicID string `json:"schematic_id"`
Version string `json:"version"`
Assets map[string]Asset `json:"assets"`
Complete bool `json:"complete"`
}
// GetAssetDir returns the asset directory for a schematic
func (m *Manager) GetAssetDir(schematicID string) string {
return filepath.Join(m.dataDir, "assets", schematicID)
}
// GetAssetsRootDir returns the root assets directory
func (m *Manager) GetAssetsRootDir() string {
return filepath.Join(m.dataDir, "assets")
}
// ListSchematics returns all available schematics
func (m *Manager) ListSchematics() ([]Schematic, error) {
assetsDir := m.GetAssetsRootDir()
// Ensure assets directory exists
if err := storage.EnsureDir(assetsDir, 0755); err != nil {
return nil, fmt.Errorf("ensuring assets directory: %w", err)
}
entries, err := os.ReadDir(assetsDir)
if err != nil {
return nil, fmt.Errorf("reading assets directory: %w", err)
}
var schematics []Schematic
for _, entry := range entries {
if entry.IsDir() {
schematicID := entry.Name()
schematic, err := m.GetSchematic(schematicID)
if err != nil {
// Skip invalid schematics
continue
}
schematics = append(schematics, *schematic)
}
}
return schematics, nil
}
// GetSchematic returns details for a specific schematic
func (m *Manager) GetSchematic(schematicID string) (*Schematic, error) {
if schematicID == "" {
return nil, fmt.Errorf("schematic ID cannot be empty")
}
assetDir := m.GetAssetDir(schematicID)
// Check if schematic directory exists
if !storage.FileExists(assetDir) {
return nil, fmt.Errorf("schematic %s not found", schematicID)
}
// List assets for this schematic
assets, err := m.listSchematicAssets(schematicID)
if err != nil {
return nil, fmt.Errorf("listing schematic assets: %w", err)
}
// Try to determine version from version file
version := ""
versionPath := filepath.Join(assetDir, "version.txt")
if storage.FileExists(versionPath) {
data, err := os.ReadFile(versionPath)
if err == nil {
version = strings.TrimSpace(string(data))
}
}
return &Schematic{
SchematicID: schematicID,
Version: version,
Path: assetDir,
Assets: assets,
}, nil
}
// listSchematicAssets lists all assets for a schematic
func (m *Manager) listSchematicAssets(schematicID string) ([]Asset, error) {
assetDir := m.GetAssetDir(schematicID)
var assets []Asset
// Check for PXE assets (kernel and initramfs for both platforms)
pxeDir := filepath.Join(assetDir, "pxe")
pxePatterns := []string{
"kernel-amd64",
"kernel-arm64",
"initramfs-amd64.xz",
"initramfs-arm64.xz",
}
for _, pattern := range pxePatterns {
assetPath := filepath.Join(pxeDir, pattern)
info, err := os.Stat(assetPath)
var assetType string
if strings.HasPrefix(pattern, "kernel-") {
assetType = "kernel"
} else {
assetType = "initramfs"
}
asset := Asset{
Type: assetType,
Path: assetPath,
Downloaded: err == nil,
}
if err == nil && info != nil {
asset.Size = info.Size()
// Calculate SHA256 if file exists
if hash, err := calculateSHA256(assetPath); err == nil {
asset.SHA256 = hash
}
}
assets = append(assets, asset)
}
// Check for ISO assets (glob pattern to find all ISOs)
isoDir := filepath.Join(assetDir, "iso")
isoMatches, err := filepath.Glob(filepath.Join(isoDir, "talos-*.iso"))
if err == nil {
for _, isoPath := range isoMatches {
info, err := os.Stat(isoPath)
asset := Asset{
Type: "iso",
Path: isoPath,
Downloaded: err == nil,
}
if err == nil && info != nil {
asset.Size = info.Size()
// Calculate SHA256 if file exists
if hash, err := calculateSHA256(isoPath); err == nil {
asset.SHA256 = hash
}
}
assets = append(assets, asset)
}
}
return assets, nil
}
// DownloadAssets downloads specified assets for a schematic
func (m *Manager) DownloadAssets(schematicID, version, platform string, assetTypes []string) error {
if schematicID == "" {
return fmt.Errorf("schematic ID cannot be empty")
}
if version == "" {
return fmt.Errorf("version cannot be empty")
}
if platform == "" {
platform = "amd64" // Default to amd64
}
// Validate platform
if platform != "amd64" && platform != "arm64" {
return fmt.Errorf("invalid platform: %s (must be amd64 or arm64)", platform)
}
if len(assetTypes) == 0 {
// Default to all asset types
assetTypes = []string{"kernel", "initramfs", "iso"}
}
assetDir := m.GetAssetDir(schematicID)
// Ensure asset directory exists
if err := storage.EnsureDir(assetDir, 0755); err != nil {
return fmt.Errorf("creating asset directory: %w", err)
}
// Save version info
versionPath := filepath.Join(assetDir, "version.txt")
if err := os.WriteFile(versionPath, []byte(version), 0644); err != nil {
return fmt.Errorf("saving version info: %w", err)
}
// Download each requested asset
for _, assetType := range assetTypes {
if err := m.downloadAsset(schematicID, assetType, version, platform); err != nil {
return fmt.Errorf("downloading %s: %w", assetType, err)
}
}
return nil
}
// downloadAsset downloads a single asset
func (m *Manager) downloadAsset(schematicID, assetType, version, platform string) error {
assetDir := m.GetAssetDir(schematicID)
// Determine subdirectory, filename, and URL based on asset type and platform
var subdir, filename, urlPath string
switch assetType {
case "kernel":
subdir = "pxe"
filename = fmt.Sprintf("kernel-%s", platform)
urlPath = fmt.Sprintf("kernel-%s", platform)
case "initramfs":
subdir = "pxe"
filename = fmt.Sprintf("initramfs-%s.xz", platform)
urlPath = fmt.Sprintf("initramfs-%s.xz", platform)
case "iso":
subdir = "iso"
// Preserve version and platform in filename for clarity
filename = fmt.Sprintf("talos-%s-metal-%s.iso", version, platform)
urlPath = fmt.Sprintf("metal-%s.iso", platform)
default:
return fmt.Errorf("unknown asset type: %s", assetType)
}
// Create subdirectory structure
assetTypeDir := filepath.Join(assetDir, subdir)
if err := storage.EnsureDir(assetTypeDir, 0755); err != nil {
return fmt.Errorf("creating %s directory: %w", subdir, err)
}
assetPath := filepath.Join(assetTypeDir, filename)
// Skip if asset already exists (idempotency)
if storage.FileExists(assetPath) {
return nil
}
// Construct download URL from Image Factory
url := fmt.Sprintf("https://factory.talos.dev/image/%s/%s/%s", schematicID, version, urlPath)
// Download file
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("downloading from %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status %d from %s", resp.StatusCode, url)
}
// Create temporary file
tmpFile := assetPath + ".tmp"
out, err := os.Create(tmpFile)
if err != nil {
return fmt.Errorf("creating temporary file: %w", err)
}
defer out.Close()
// Copy data
_, err = io.Copy(out, resp.Body)
if err != nil {
os.Remove(tmpFile)
return fmt.Errorf("writing file: %w", err)
}
// Close file before rename
out.Close()
// Move to final location
if err := os.Rename(tmpFile, assetPath); err != nil {
os.Remove(tmpFile)
return fmt.Errorf("moving file to final location: %w", err)
}
return nil
}
// GetAssetStatus returns the download status for a schematic
func (m *Manager) GetAssetStatus(schematicID string) (*AssetStatus, error) {
if schematicID == "" {
return nil, fmt.Errorf("schematic ID cannot be empty")
}
assetDir := m.GetAssetDir(schematicID)
// Check if schematic directory exists
if !storage.FileExists(assetDir) {
return nil, fmt.Errorf("schematic %s not found", schematicID)
}
// Get version
version := ""
versionPath := filepath.Join(assetDir, "version.txt")
if storage.FileExists(versionPath) {
data, err := os.ReadFile(versionPath)
if err == nil {
version = strings.TrimSpace(string(data))
}
}
// List assets
assets, err := m.listSchematicAssets(schematicID)
if err != nil {
return nil, fmt.Errorf("listing assets: %w", err)
}
// Build asset map and check completion
assetMap := make(map[string]Asset)
complete := true
for _, asset := range assets {
assetMap[asset.Type] = asset
if !asset.Downloaded {
complete = false
}
}
return &AssetStatus{
SchematicID: schematicID,
Version: version,
Assets: assetMap,
Complete: complete,
}, nil
}
// GetAssetPath returns the path to a specific asset file
func (m *Manager) GetAssetPath(schematicID, assetType string) (string, error) {
if schematicID == "" {
return "", fmt.Errorf("schematic ID cannot be empty")
}
assetDir := m.GetAssetDir(schematicID)
var subdir, pattern string
switch assetType {
case "kernel":
subdir = "pxe"
pattern = "kernel-amd64"
case "initramfs":
subdir = "pxe"
pattern = "initramfs-amd64.xz"
case "iso":
subdir = "iso"
pattern = "talos-*.iso" // Glob pattern for version-specific filename
default:
return "", fmt.Errorf("unknown asset type: %s", assetType)
}
assetTypeDir := filepath.Join(assetDir, subdir)
// Find matching file (supports glob pattern for ISO)
var assetPath string
if strings.Contains(pattern, "*") {
matches, err := filepath.Glob(filepath.Join(assetTypeDir, pattern))
if err != nil {
return "", fmt.Errorf("searching for asset: %w", err)
}
if len(matches) == 0 {
return "", fmt.Errorf("asset %s not found for schematic %s", assetType, schematicID)
}
assetPath = matches[0] // Use first match
} else {
assetPath = filepath.Join(assetTypeDir, pattern)
}
if !storage.FileExists(assetPath) {
return "", fmt.Errorf("asset %s not found for schematic %s", assetType, schematicID)
}
return assetPath, nil
}
// DeleteSchematic removes a schematic and all its assets
func (m *Manager) DeleteSchematic(schematicID string) error {
if schematicID == "" {
return fmt.Errorf("schematic ID cannot be empty")
}
assetDir := m.GetAssetDir(schematicID)
if !storage.FileExists(assetDir) {
return nil // Already deleted, idempotent
}
return os.RemoveAll(assetDir)
}
// calculateSHA256 computes the SHA256 hash of a file
func calculateSHA256(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}