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
This commit is contained in:
348
pkg/cdn/client.go
Normal file
348
pkg/cdn/client.go
Normal file
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user