* feat(devops): migrate filesystem operations to io.Local abstraction Migrate config.go: - os.ReadFile → io.Local.Read Migrate devops.go: - os.Stat → io.Local.IsFile Migrate images.go: - os.MkdirAll → io.Local.EnsureDir - os.Stat → io.Local.IsFile - os.ReadFile → io.Local.Read - os.WriteFile → io.Local.Write Migrate test.go: - os.ReadFile → io.Local.Read - os.Stat → io.Local.IsFile Migrate claude.go: - os.Stat → io.Local.IsDir Updated tests to reflect improved behavior: - Manifest.Save() now creates parent directories - hasFile() correctly returns false for directories Part of #101 (io.Medium migration tracking issue). Closes #107 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore(io): migrate remaining packages to io.Local abstraction Migrate filesystem operations to use the io.Local abstraction for improved security, testability, and consistency: - pkg/cache: Replace os.ReadFile, WriteFile, Remove, RemoveAll with io.Local equivalents. io.Local.Write creates parent dirs automatically. - pkg/agentic: Migrate config.go and context.go to use io.Local for reading config files and gathering file context. - pkg/repos: Use io.Local.Read, Exists, IsDir, List for registry operations and git repo detection. - pkg/release: Use io.Local for config loading, existence checks, and artifact discovery. - pkg/devops/sources: Use io.Local.EnsureDir for CDN download. All paths are converted to absolute using filepath.Abs() before calling io.Local methods to handle relative paths correctly. Closes #104, closes #106, closes #108, closes #111 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore(io): migrate pkg/cli and pkg/container to io.Local abstraction Continue io.Medium migration for the remaining packages: - pkg/cli/daemon.go: PIDFile Acquire/Release now use io.Local.Read, Delete, and Write for managing daemon PID files. - pkg/container/state.go: LoadState and SaveState use io.Local for JSON state persistence. EnsureLogsDir uses io.Local.EnsureDir. - pkg/container/templates.go: Template loading and directory scanning now use io.Local.IsFile, IsDir, Read, and List. - pkg/container/linuxkit.go: Image validation uses io.Local.IsFile, log file check uses io.Local.IsFile. Streaming log file creation (os.Create) remains unchanged as io.Local doesn't support streaming. Closes #105, closes #107 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs(audit): add dependency security audit report Complete security audit of all project dependencies: - Run govulncheck: No vulnerabilities found - Run go mod verify: All modules verified - Document 15 direct dependencies and 161 indirect - Assess supply chain risks: Low risk overall - Verify lock files are committed with integrity hashes - Provide CI integration recommendations Closes #185 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): build core CLI from source instead of downloading release The workflows were trying to download from a non-existent release URL. Now builds the CLI directly using `go build` with version injection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: trigger CI with updated workflow * chore(ci): add workflow_dispatch trigger for manual runs --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
193 lines
4.1 KiB
Go
193 lines
4.1 KiB
Go
package devops
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/host-uk/core/pkg/devops/sources"
|
|
"github.com/host-uk/core/pkg/io"
|
|
)
|
|
|
|
// ImageManager handles image downloads and updates.
|
|
type ImageManager struct {
|
|
config *Config
|
|
manifest *Manifest
|
|
sources []sources.ImageSource
|
|
}
|
|
|
|
// Manifest tracks installed images.
|
|
type Manifest struct {
|
|
Images map[string]ImageInfo `json:"images"`
|
|
path string
|
|
}
|
|
|
|
// ImageInfo holds metadata about an installed image.
|
|
type ImageInfo struct {
|
|
Version string `json:"version"`
|
|
SHA256 string `json:"sha256,omitempty"`
|
|
Downloaded time.Time `json:"downloaded"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
// NewImageManager creates a new image manager.
|
|
func NewImageManager(cfg *Config) (*ImageManager, error) {
|
|
imagesDir, err := ImagesDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Ensure images directory exists
|
|
if err := io.Local.EnsureDir(imagesDir); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load or create manifest
|
|
manifestPath := filepath.Join(imagesDir, "manifest.json")
|
|
manifest, err := loadManifest(manifestPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build source list based on config
|
|
imageName := ImageName()
|
|
sourceCfg := sources.SourceConfig{
|
|
GitHubRepo: cfg.Images.GitHub.Repo,
|
|
RegistryImage: cfg.Images.Registry.Image,
|
|
CDNURL: cfg.Images.CDN.URL,
|
|
ImageName: imageName,
|
|
}
|
|
|
|
var srcs []sources.ImageSource
|
|
switch cfg.Images.Source {
|
|
case "github":
|
|
srcs = []sources.ImageSource{sources.NewGitHubSource(sourceCfg)}
|
|
case "cdn":
|
|
srcs = []sources.ImageSource{sources.NewCDNSource(sourceCfg)}
|
|
default: // "auto"
|
|
srcs = []sources.ImageSource{
|
|
sources.NewGitHubSource(sourceCfg),
|
|
sources.NewCDNSource(sourceCfg),
|
|
}
|
|
}
|
|
|
|
return &ImageManager{
|
|
config: cfg,
|
|
manifest: manifest,
|
|
sources: srcs,
|
|
}, nil
|
|
}
|
|
|
|
// IsInstalled checks if the dev image is installed.
|
|
func (m *ImageManager) IsInstalled() bool {
|
|
path, err := ImagePath()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return io.Local.IsFile(path)
|
|
}
|
|
|
|
// Install downloads and installs the dev image.
|
|
func (m *ImageManager) Install(ctx context.Context, progress func(downloaded, total int64)) error {
|
|
imagesDir, err := ImagesDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find first available source
|
|
var src sources.ImageSource
|
|
for _, s := range m.sources {
|
|
if s.Available() {
|
|
src = s
|
|
break
|
|
}
|
|
}
|
|
if src == nil {
|
|
return fmt.Errorf("no image source available")
|
|
}
|
|
|
|
// Get version
|
|
version, err := src.LatestVersion(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get latest version: %w", err)
|
|
}
|
|
|
|
fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name())
|
|
|
|
// Download
|
|
if err := src.Download(ctx, imagesDir, progress); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update manifest
|
|
m.manifest.Images[ImageName()] = ImageInfo{
|
|
Version: version,
|
|
Downloaded: time.Now(),
|
|
Source: src.Name(),
|
|
}
|
|
|
|
return m.manifest.Save()
|
|
}
|
|
|
|
// CheckUpdate checks if an update is available.
|
|
func (m *ImageManager) CheckUpdate(ctx context.Context) (current, latest string, hasUpdate bool, err error) {
|
|
info, ok := m.manifest.Images[ImageName()]
|
|
if !ok {
|
|
return "", "", false, fmt.Errorf("image not installed")
|
|
}
|
|
current = info.Version
|
|
|
|
// Find first available source
|
|
var src sources.ImageSource
|
|
for _, s := range m.sources {
|
|
if s.Available() {
|
|
src = s
|
|
break
|
|
}
|
|
}
|
|
if src == nil {
|
|
return current, "", false, fmt.Errorf("no image source available")
|
|
}
|
|
|
|
latest, err = src.LatestVersion(ctx)
|
|
if err != nil {
|
|
return current, "", false, err
|
|
}
|
|
|
|
hasUpdate = current != latest
|
|
return current, latest, hasUpdate, nil
|
|
}
|
|
|
|
func loadManifest(path string) (*Manifest, error) {
|
|
m := &Manifest{
|
|
Images: make(map[string]ImageInfo),
|
|
path: path,
|
|
}
|
|
|
|
content, err := io.Local.Read(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return m, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(content), m); err != nil {
|
|
return nil, err
|
|
}
|
|
m.path = path
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// Save writes the manifest to disk.
|
|
func (m *Manifest) Save() error {
|
|
data, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return io.Local.Write(m.path, string(data))
|
|
}
|