Files
volt/pkg/cdn/client.go
Karl Clinger 0ebe75b2ca Volt CLI: source-available under AGPSL v5.0
Complete infrastructure platform CLI:
- Container runtime (systemd-nspawn)
- VoltVisor VMs (Neutron Stardust / QEMU)
- Stellarium CAS (content-addressed storage)
- ORAS Registry
- GitOps integration
- Landlock LSM security
- Compose orchestration
- Mesh networking

Copyright (c) Armored Gates LLC. All rights reserved.
Licensed under AGPSL v5.0
2026-03-21 02:08:15 -05:00

349 lines
11 KiB
Go

/*
CDN Client — BunnyCDN blob and manifest operations for Volt CAS.
Handles pull (public, unauthenticated) and push (authenticated via AccessKey)
to the BunnyCDN storage and pull-zone endpoints that back Stellarium.
Copyright (c) Armored Gates LLC. All rights reserved.
*/
package cdn
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// ── Defaults ─────────────────────────────────────────────────────────────────
const (
DefaultBlobsURL = "https://blobs.3kb.io"
DefaultManifestsURL = "https://manifests.3kb.io"
DefaultRegion = "ny"
)
// ── Manifest ─────────────────────────────────────────────────────────────────
// Manifest represents a CAS build manifest as stored on the CDN.
type Manifest struct {
Name string `json:"name"`
CreatedAt string `json:"created_at"`
Objects map[string]string `json:"objects"` // relative path → sha256 hash
}
// ── Client ───────────────────────────────────────────────────────────────────
// Client handles blob upload/download to BunnyCDN.
type Client struct {
BlobsBaseURL string // pull-zone URL for blobs, e.g. https://blobs.3kb.io
ManifestsBaseURL string // pull-zone URL for manifests, e.g. https://manifests.3kb.io
StorageAPIKey string // BunnyCDN storage zone API key
StorageZoneName string // BunnyCDN storage zone name
Region string // BunnyCDN region, e.g. "ny"
HTTPClient *http.Client
}
// ── CDN Config (from config.yaml) ────────────────────────────────────────────
// CDNConfig represents the cdn section of /etc/volt/config.yaml.
type CDNConfig struct {
BlobsURL string `yaml:"blobs_url"`
ManifestsURL string `yaml:"manifests_url"`
StorageAPIKey string `yaml:"storage_api_key"`
StorageZone string `yaml:"storage_zone"`
Region string `yaml:"region"`
}
// voltConfig is a minimal representation of the config file, just enough to
// extract the cdn block.
type voltConfig struct {
CDN CDNConfig `yaml:"cdn"`
}
// ── Constructors ─────────────────────────────────────────────────────────────
// NewClient creates a CDN client by reading config from /etc/volt/config.yaml
// (if present) and falling back to environment variables.
func NewClient() (*Client, error) {
return NewClientFromConfigFile("")
}
// NewClientFromConfigFile creates a CDN client from a specific config file
// path. If configPath is empty, it tries /etc/volt/config.yaml.
func NewClientFromConfigFile(configPath string) (*Client, error) {
var cfg CDNConfig
// Try to load from config file.
if configPath == "" {
configPath = "/etc/volt/config.yaml"
}
if data, err := os.ReadFile(configPath); err == nil {
var vc voltConfig
if err := yaml.Unmarshal(data, &vc); err == nil {
cfg = vc.CDN
}
}
// Expand environment variable references in config values (e.g. "${BUNNY_API_KEY}").
cfg.BlobsURL = expandEnv(cfg.BlobsURL)
cfg.ManifestsURL = expandEnv(cfg.ManifestsURL)
cfg.StorageAPIKey = expandEnv(cfg.StorageAPIKey)
cfg.StorageZone = expandEnv(cfg.StorageZone)
cfg.Region = expandEnv(cfg.Region)
// Override with environment variables if config values are empty.
if cfg.BlobsURL == "" {
cfg.BlobsURL = os.Getenv("VOLT_CDN_BLOBS_URL")
}
if cfg.ManifestsURL == "" {
cfg.ManifestsURL = os.Getenv("VOLT_CDN_MANIFESTS_URL")
}
if cfg.StorageAPIKey == "" {
cfg.StorageAPIKey = os.Getenv("BUNNY_API_KEY")
}
if cfg.StorageZone == "" {
cfg.StorageZone = os.Getenv("BUNNY_STORAGE_ZONE")
}
if cfg.Region == "" {
cfg.Region = os.Getenv("BUNNY_REGION")
}
// Apply defaults.
if cfg.BlobsURL == "" {
cfg.BlobsURL = DefaultBlobsURL
}
if cfg.ManifestsURL == "" {
cfg.ManifestsURL = DefaultManifestsURL
}
if cfg.Region == "" {
cfg.Region = DefaultRegion
}
return &Client{
BlobsBaseURL: strings.TrimRight(cfg.BlobsURL, "/"),
ManifestsBaseURL: strings.TrimRight(cfg.ManifestsURL, "/"),
StorageAPIKey: cfg.StorageAPIKey,
StorageZoneName: cfg.StorageZone,
Region: cfg.Region,
HTTPClient: &http.Client{
Timeout: 5 * time.Minute,
},
}, nil
}
// NewClientFromConfig creates a CDN client from explicit parameters.
func NewClientFromConfig(blobsURL, manifestsURL, apiKey, zoneName string) *Client {
if blobsURL == "" {
blobsURL = DefaultBlobsURL
}
if manifestsURL == "" {
manifestsURL = DefaultManifestsURL
}
return &Client{
BlobsBaseURL: strings.TrimRight(blobsURL, "/"),
ManifestsBaseURL: strings.TrimRight(manifestsURL, "/"),
StorageAPIKey: apiKey,
StorageZoneName: zoneName,
Region: DefaultRegion,
HTTPClient: &http.Client{
Timeout: 5 * time.Minute,
},
}
}
// ── Pull Operations (public, no auth) ────────────────────────────────────────
// PullBlob downloads a blob by hash from the CDN pull zone and verifies its
// SHA-256 integrity. Returns the raw content.
func (c *Client) PullBlob(hash string) ([]byte, error) {
url := fmt.Sprintf("%s/sha256:%s", c.BlobsBaseURL, hash)
resp, err := c.HTTPClient.Get(url)
if err != nil {
return nil, fmt.Errorf("cdn pull blob %s: %w", hash[:12], err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cdn pull blob %s: HTTP %d", hash[:12], resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cdn pull blob %s: read body: %w", hash[:12], err)
}
// Verify integrity.
actualHash := sha256Hex(data)
if actualHash != hash {
return nil, fmt.Errorf("cdn pull blob %s: integrity check failed (got %s)", hash[:12], actualHash[:12])
}
return data, nil
}
// PullManifest downloads a manifest by name from the CDN manifests pull zone.
func (c *Client) PullManifest(name string) (*Manifest, error) {
url := fmt.Sprintf("%s/v2/public/%s/latest.json", c.ManifestsBaseURL, name)
resp, err := c.HTTPClient.Get(url)
if err != nil {
return nil, fmt.Errorf("cdn pull manifest %s: %w", name, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("cdn pull manifest %s: not found", name)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cdn pull manifest %s: HTTP %d", name, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cdn pull manifest %s: read body: %w", name, err)
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("cdn pull manifest %s: unmarshal: %w", name, err)
}
return &m, nil
}
// BlobExists checks whether a blob exists on the CDN using a HEAD request.
func (c *Client) BlobExists(hash string) (bool, error) {
url := fmt.Sprintf("%s/sha256:%s", c.BlobsBaseURL, hash)
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
return false, fmt.Errorf("cdn blob exists %s: %w", hash[:12], err)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return false, fmt.Errorf("cdn blob exists %s: %w", hash[:12], err)
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("cdn blob exists %s: HTTP %d", hash[:12], resp.StatusCode)
}
}
// ── Push Operations (authenticated) ──────────────────────────────────────────
// PushBlob uploads a blob to BunnyCDN storage. The hash must match the SHA-256
// of the data. Requires StorageAPIKey and StorageZoneName to be set.
func (c *Client) PushBlob(hash string, data []byte) error {
if c.StorageAPIKey == "" {
return fmt.Errorf("cdn push blob: StorageAPIKey not configured")
}
if c.StorageZoneName == "" {
return fmt.Errorf("cdn push blob: StorageZoneName not configured")
}
// Verify the hash matches the data.
actualHash := sha256Hex(data)
if actualHash != hash {
return fmt.Errorf("cdn push blob: hash mismatch (expected %s, got %s)", hash[:12], actualHash[:12])
}
// BunnyCDN storage upload endpoint.
url := fmt.Sprintf("https://%s.storage.bunnycdn.com/%s/sha256:%s",
c.Region, c.StorageZoneName, hash)
req, err := http.NewRequest(http.MethodPut, url, strings.NewReader(string(data)))
if err != nil {
return fmt.Errorf("cdn push blob %s: create request: %w", hash[:12], err)
}
req.Header.Set("AccessKey", c.StorageAPIKey)
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = int64(len(data))
resp, err := c.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("cdn push blob %s: %w", hash[:12], err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("cdn push blob %s: HTTP %d: %s", hash[:12], resp.StatusCode, string(body))
}
return nil
}
// PushManifest uploads a manifest to BunnyCDN storage under the conventional
// path: v2/public/{name}/latest.json
func (c *Client) PushManifest(name string, manifest *Manifest) error {
if c.StorageAPIKey == "" {
return fmt.Errorf("cdn push manifest: StorageAPIKey not configured")
}
if c.StorageZoneName == "" {
return fmt.Errorf("cdn push manifest: StorageZoneName not configured")
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("cdn push manifest %s: marshal: %w", name, err)
}
// Upload to manifests storage zone path.
url := fmt.Sprintf("https://%s.storage.bunnycdn.com/%s/v2/public/%s/latest.json",
c.Region, c.StorageZoneName, name)
req, err := http.NewRequest(http.MethodPut, url, strings.NewReader(string(data)))
if err != nil {
return fmt.Errorf("cdn push manifest %s: create request: %w", name, err)
}
req.Header.Set("AccessKey", c.StorageAPIKey)
req.Header.Set("Content-Type", "application/json")
req.ContentLength = int64(len(data))
resp, err := c.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("cdn push manifest %s: %w", name, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("cdn push manifest %s: HTTP %d: %s", name, resp.StatusCode, string(body))
}
return nil
}
// ── Helpers ──────────────────────────────────────────────────────────────────
// sha256Hex computes the SHA-256 hex digest of data.
func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// expandEnv expands "${VAR}" patterns in a string. Only the ${VAR} form is
// expanded (not $VAR) to avoid accidental substitution.
func expandEnv(s string) string {
if !strings.Contains(s, "${") {
return s
}
return os.Expand(s, os.Getenv)
}