captaincore/cmd/helpers.go
Austin Ginder c2a966182a 🐛 FIX: Path
2026-03-22 16:50:52 -04:00

702 lines
20 KiB
Go

package cmd
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/CaptainCore/captaincore/apiclient"
"github.com/CaptainCore/captaincore/config"
"github.com/CaptainCore/captaincore/models"
)
// SiteArg holds parsed components from a "site-environment@provider" argument.
type SiteArg struct {
SiteName string
Environment string // defaults to "production"
Provider string
}
// parseSiteArgument parses a "site-environment@provider" string into its components.
func parseSiteArgument(arg string) SiteArg {
sa := SiteArg{SiteName: arg, Environment: "production"}
// Parse site-environment format
if strings.Contains(sa.SiteName, "-") {
parts := strings.SplitN(sa.SiteName, "-", 2)
sa.SiteName = parts[0]
sa.Environment = parts[1]
}
// Parse @provider from site name
if strings.Contains(sa.SiteName, "@") {
parts := strings.SplitN(sa.SiteName, "@", 2)
sa.SiteName = parts[0]
sa.Provider = parts[1]
}
// Parse @provider from environment
if strings.Contains(sa.Environment, "@") {
parts := strings.SplitN(sa.Environment, "@", 2)
sa.Environment = parts[0]
sa.Provider = parts[1]
}
return sa
}
// LookupSite finds the site in the database matching this SiteArg.
func (sa SiteArg) LookupSite() (*models.Site, error) {
if sa.Provider != "" {
return models.GetSiteByNameAndProvider(sa.SiteName, sa.Provider)
}
if id, err := strconv.ParseUint(sa.SiteName, 10, 64); err == nil {
return models.GetSiteByID(uint(id))
}
return models.GetSiteByName(sa.SiteName)
}
// LookupEnvironment finds the matching environment for a site.
func (sa SiteArg) LookupEnvironment(siteID uint) (*models.Environment, error) {
environments, err := models.FindEnvironmentsBySiteID(siteID)
if err != nil {
return nil, err
}
for i, e := range environments {
if strings.EqualFold(e.Environment, sa.Environment) {
return &environments[i], nil
}
}
return nil, nil
}
// loadCaptainConfig loads config.json and returns system + captain configs for the current captain ID.
func loadCaptainConfig() (config.FullConfig, *config.SystemConfig, *config.CaptainConfig, error) {
configs, err := config.LoadConfig()
if err != nil {
return nil, nil, nil, err
}
system := configs.GetSystem()
captain := configs.GetCaptain(captainID)
// Adjust path for fleet mode
if system != nil && system.CaptainCoreFleet == "true" {
system.Path = system.Path + "/" + captainID
}
return configs, system, captain, nil
}
// newAPIClient creates an API client from the config.
func newAPIClient(system *config.SystemConfig, captain *config.CaptainConfig) *apiclient.Client {
apiURL := ""
token := ""
skipSSL := false
if captain != nil {
if v, ok := captain.Vars["captaincore_api"]; ok {
json.Unmarshal(v, &apiURL)
}
if v, ok := captain.Keys["token"]; ok {
token = v
}
}
if system != nil && system.CaptainCoreDev != "" && system.CaptainCoreDev != "false" {
skipSSL = true
}
return apiclient.NewClient(apiURL, token, skipSSL)
}
// getRcloneBackup returns the rclone backup remote path, adjusted for fleet mode.
func getRcloneBackup(captain *config.CaptainConfig, system *config.SystemConfig) string {
backup := ""
if captain != nil {
backup = captain.Remotes["rclone_backup"]
}
if backup == "" && system != nil {
backup = system.RcloneBackup
}
if system != nil && system.CaptainCoreFleet == "true" {
backup = backup + "/" + captainID
}
return backup
}
// getResticKeyPath returns the path to the restic password key file.
func getResticKeyPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".captaincore", "data", "restic.key")
}
// getBackupLockPath returns the path to the backup lock file for a site/environment.
func getBackupLockPath(siteName string, siteID uint, envName string) string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".captaincore", "data", fmt.Sprintf("%s_%d", siteName, siteID), envName, "backup.lock")
}
// acquireBackupLock attempts to acquire the backup lock for a site/environment.
// Returns true if the lock was acquired, false if another process holds it.
func acquireBackupLock(lockPath string) bool {
// Check if lock file exists and process is still running
if data, err := os.ReadFile(lockPath); err == nil {
pidStr := strings.TrimSpace(string(data))
if pidStr != "" {
// Check if process is still alive
checkCmd := fmt.Sprintf("/proc/%s", pidStr)
if _, err := os.Stat(checkCmd); err == nil {
return false
}
// Also try kill -0 approach for macOS compatibility
if pid, err := strconv.Atoi(pidStr); err == nil {
process, err := os.FindProcess(pid)
if err == nil {
// On Unix, FindProcess always succeeds; use Signal(0) to check
if err := process.Signal(os.Signal(nil)); err == nil {
return false
}
}
}
// Stale lock, remove it
}
}
// Ensure directory exists
os.MkdirAll(filepath.Dir(lockPath), 0755)
// Write our PID
return os.WriteFile(lockPath, []byte(fmt.Sprintf("%d", os.Getpid())), 0644) == nil
}
// releaseBackupLock removes the backup lock file.
func releaseBackupLock(lockPath string) {
os.Remove(lockPath)
}
// backupRepoKey downloads the restic repo key from B2 and saves it locally as backup-repo-key.txt
// or quicksave-repo-key.txt next to vault.txt. This protects against B2 data loss.
func backupRepoKey(siteName string, siteID uint, envName string, rcloneBackup string, repoType string, dataPath string) error {
siteDir := fmt.Sprintf("%s_%d", siteName, siteID)
// Determine key filename based on repo type
keyFileName := "backup-repo-key.txt"
repoName := "restic-repo"
if repoType == "quicksave" {
keyFileName = "quicksave-repo-key.txt"
repoName = "quicksave-repo"
}
localKeyPath := filepath.Join(dataPath, siteDir, envName, keyFileName)
remoteKeysPath := fmt.Sprintf("%s/%s/%s/%s/keys/", rcloneBackup, siteDir, envName, repoName)
// List key files in the repo
listCmd := exec.Command("rclone", "lsf", remoteKeysPath)
listOutput, err := listCmd.Output()
if err != nil {
return fmt.Errorf("failed to list repo keys: %w", err)
}
keyFiles := strings.TrimSpace(string(listOutput))
if keyFiles == "" {
return fmt.Errorf("no key files found in repo")
}
// Use the first key file
firstKey := strings.Split(keyFiles, "\n")[0]
remoteKeyFile := remoteKeysPath + firstKey
// Download key content
catCmd := exec.Command("rclone", "cat", remoteKeyFile)
keyContent, err := catCmd.Output()
if err != nil {
return fmt.Errorf("failed to download repo key: %w", err)
}
// Ensure directory exists and save
os.MkdirAll(filepath.Dir(localKeyPath), 0755)
if err := os.WriteFile(localKeyPath, keyContent, 0600); err != nil {
return fmt.Errorf("failed to save repo key: %w", err)
}
fmt.Printf("Repo key backed up to %s\n", localKeyPath)
return nil
}
// restoreRepoKey uploads a locally backed-up key to the restic repo in B2.
func restoreRepoKey(siteName string, siteID uint, envName string, rcloneBackup string, repoType string, dataPath string) error {
siteDir := fmt.Sprintf("%s_%d", siteName, siteID)
keyFileName := "backup-repo-key.txt"
repoName := "restic-repo"
if repoType == "quicksave" {
keyFileName = "quicksave-repo-key.txt"
repoName = "quicksave-repo"
}
localKeyPath := filepath.Join(dataPath, siteDir, envName, keyFileName)
remoteKeysPath := fmt.Sprintf("%s/%s/%s/%s/keys/", rcloneBackup, siteDir, envName, repoName)
// Read local key
keyContent, err := os.ReadFile(localKeyPath)
if err != nil {
return fmt.Errorf("no local key backup found at %s", localKeyPath)
}
// Parse key to get the key hash for the filename
var keyData struct {
Created string `json:"created"`
}
if json.Unmarshal(keyContent, &keyData) != nil {
return fmt.Errorf("invalid key file format")
}
// Generate SHA256 hash of key content for filename (matches restic's naming)
h := sha256.Sum256(keyContent)
keyHash := fmt.Sprintf("%x", h)
// Write to temp file and upload
tmpFile := filepath.Join(os.TempDir(), keyHash)
if err := os.WriteFile(tmpFile, keyContent, 0600); err != nil {
return fmt.Errorf("failed to write temp key: %w", err)
}
defer os.Remove(tmpFile)
uploadCmd := exec.Command("rclone", "copyto", tmpFile, remoteKeysPath+keyHash)
if err := uploadCmd.Run(); err != nil {
return fmt.Errorf("failed to upload repo key: %w", err)
}
fmt.Printf("Repo key restored to %s%s\n", remoteKeysPath, keyHash)
return nil
}
// updateEnvironmentDetails merges updates into environment details JSON, saves to DB, and posts to API.
func updateEnvironmentDetails(envID uint, siteID uint, updates map[string]interface{}, system *config.SystemConfig, captain *config.CaptainConfig) error {
env, err := models.GetEnvironmentByID(envID)
if err != nil {
return err
}
var details map[string]interface{}
if env.Details != "" {
json.Unmarshal([]byte(env.Details), &details)
}
if details == nil {
details = make(map[string]interface{})
}
for k, v := range updates {
if v == nil {
delete(details, k)
} else {
details[k] = v
}
}
detailsJSON, _ := json.Marshal(details)
timeNow := time.Now().UTC().Format("2006-01-02 15:04:05")
models.DB.Model(&models.Environment{}).Where("environment_id = ?", envID).Updates(map[string]interface{}{
"details": string(detailsJSON),
"updated_at": timeNow,
})
client := newAPIClient(system, captain)
envUpdate := map[string]interface{}{
"environment_id": envID,
"details": string(detailsJSON),
"updated_at": timeNow,
}
client.Post("update-environment", map[string]interface{}{
"site_id": siteID,
"data": envUpdate,
})
return nil
}
// updateSiteDetails merges updates into site details JSON, saves to DB, and posts to API.
// Pass nil values to delete keys from the details object.
func updateSiteDetails(siteID uint, updates map[string]interface{}, system *config.SystemConfig, captain *config.CaptainConfig) error {
site, err := models.GetSiteByID(siteID)
if err != nil {
return err
}
var details map[string]interface{}
if site.Details != "" {
json.Unmarshal([]byte(site.Details), &details)
}
if details == nil {
details = make(map[string]interface{})
}
for k, v := range updates {
if v == nil {
delete(details, k)
} else {
details[k] = v
}
}
detailsJSON, _ := json.Marshal(details)
timeNow := time.Now().UTC().Format("2006-01-02 15:04:05")
models.DB.Model(&models.Site{}).Where("site_id = ?", siteID).Updates(map[string]interface{}{
"details": string(detailsJSON),
"updated_at": timeNow,
})
client := newAPIClient(system, captain)
siteUpdate := map[string]interface{}{
"site_id": siteID,
"details": string(detailsJSON),
}
client.Post("update-site", map[string]interface{}{
"site_id": siteID,
"data": siteUpdate,
})
return nil
}
// getVarString extracts a string value from a captain's vars map.
func getVarString(captain *config.CaptainConfig, key string) string {
if captain == nil {
return ""
}
v, ok := captain.Vars[key]
if !ok {
return ""
}
var s string
if err := json.Unmarshal(v, &s); err == nil {
return s
}
return strings.TrimSpace(string(v))
}
// b2AuthorizeDownload performs the two-step Backblaze B2 authorization flow
// and returns a download authorization token.
func b2AuthorizeDownload(accountID, accountKey, bucketID, fileNamePrefix string) (string, error) {
// Step 1: Authorize account
credentials := base64.StdEncoding.EncodeToString([]byte(accountID + ":" + accountKey))
req, err := http.NewRequest("GET", "https://api.backblazeb2.com/b2api/v1/b2_authorize_account", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Basic "+credentials)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var authResp struct {
AuthorizationToken string `json:"authorizationToken"`
}
if err := json.Unmarshal(body, &authResp); err != nil {
return "", err
}
// Step 2: Get download authorization
postData, _ := json.Marshal(map[string]interface{}{
"bucketId": bucketID,
"validDurationInSeconds": 604800,
"fileNamePrefix": fileNamePrefix,
})
req2, err := http.NewRequest("POST", "https://api001.backblazeb2.com/b2api/v1/b2_get_download_authorization", bytes.NewReader(postData))
if err != nil {
return "", err
}
req2.Header.Set("Authorization", authResp.AuthorizationToken)
resp2, err := client.Do(req2)
if err != nil {
return "", err
}
defer resp2.Body.Close()
body2, err := io.ReadAll(resp2.Body)
if err != nil {
return "", err
}
var dlResp struct {
AuthorizationToken string `json:"authorizationToken"`
}
if err := json.Unmarshal(body2, &dlResp); err != nil {
return "", err
}
return dlResp.AuthorizationToken, nil
}
// parseThresholdDuration converts a human-friendly threshold string into a time.Duration.
// Supports "24h" (hours), "7d" (days), "30m" (minutes).
func parseThresholdDuration(threshold string) (time.Duration, error) {
threshold = strings.TrimSpace(strings.ToLower(threshold))
if len(threshold) < 2 {
return 0, fmt.Errorf("invalid threshold: %s", threshold)
}
unit := threshold[len(threshold)-1]
numStr := threshold[:len(threshold)-1]
num, err := strconv.Atoi(numStr)
if err != nil {
return 0, fmt.Errorf("invalid threshold number: %s", threshold)
}
switch unit {
case 'h':
return time.Duration(num) * time.Hour, nil
case 'd':
return time.Duration(num) * 24 * time.Hour, nil
case 'm':
return time.Duration(num) * time.Minute, nil
default:
return 0, fmt.Errorf("unsupported threshold unit: %c", unit)
}
}
// checkLastRun replicates lib/local-scripts/check-last-run.php in Go.
// Returns true if the site should be skipped (was run recently within threshold).
func checkLastRun(listFilePath string, threshold string) bool {
dur, err := parseThresholdDuration(threshold)
if err != nil {
return false
}
info, err := os.Stat(listFilePath)
if err != nil {
return false
}
fileMtime := info.ModTime()
// Parse JSON content for timestamps
data, err := os.ReadFile(listFilePath)
if err != nil {
return false
}
var lastEntryTime time.Time
var items []map[string]interface{}
if json.Unmarshal(data, &items) == nil && len(items) > 0 {
for _, item := range items {
if createdAt, ok := item["created_at"]; ok {
var ts int64
switch v := createdAt.(type) {
case float64:
ts = int64(v)
case string:
ts, _ = strconv.ParseInt(v, 10, 64)
}
if ts > 0 {
t := time.Unix(ts, 0)
if t.After(lastEntryTime) {
lastEntryTime = t
}
}
} else if timeStr, ok := item["time"].(string); ok && timeStr != "" {
t, parseErr := time.Parse(time.RFC3339Nano, timeStr)
if parseErr != nil {
t, parseErr = time.Parse("2006-01-02T15:04:05Z07:00", timeStr)
}
if parseErr == nil && t.After(lastEntryTime) {
lastEntryTime = t
}
}
}
}
// Determine most recent activity
lastActivity := fileMtime
if lastEntryTime.After(lastActivity) {
lastActivity = lastEntryTime
}
cutoff := time.Now().Add(-dur)
return lastActivity.After(cutoff)
}
// dryRunGenerate previews which environments would be processed by generate commands.
// listSubdir is "quicksaves" or "backups".
func dryRunGenerate(target string, listSubdir string) {
if !ensureDB() || !dbHasData() {
fmt.Println("Error: Database not available. Run 'captaincore connect' to set up your CaptainCore CLI.")
return
}
// Check if target looks like a bulk target (@all, @production, @staging) or single site
isBulk := strings.HasPrefix(target, "@")
if isBulk {
environment, minorTargets := models.ParseTargetString(target)
queryArgs := models.FetchSiteMatchingArgs{
Environment: environment,
Targets: minorTargets,
}
results, err := models.FetchSitesMatching(queryArgs)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
_, system, _, err := loadCaptainConfig()
if err != nil || system == nil {
fmt.Println("Error: Configuration file not found.")
return
}
var toDo []string
skipped := 0
for _, r := range results {
envLower := strings.ToLower(r.Environment)
siteEnv := fmt.Sprintf("%s-%s", r.Site, envLower)
siteDir := fmt.Sprintf("%s_%d", r.Site, r.SiteID)
listPath := filepath.Join(system.Path, siteDir, envLower, listSubdir, "list.json")
if flagSkipIfRecent != "" && checkLastRun(listPath, flagSkipIfRecent) {
skipped++
} else {
toDo = append(toDo, siteEnv)
}
}
if flagSkipIfRecent != "" {
fmt.Println("\nEnvironments to process:")
for _, env := range toDo {
fmt.Printf(" %s\n", env)
}
fmt.Printf("\nenvironments skipped: %d\n", skipped)
fmt.Printf("environments to do: %d\n", len(toDo))
} else {
fmt.Printf("\nenvironments to do: %d\n", len(toDo))
fmt.Println("(no --skip-if-recent specified, all environments would be processed)")
}
} else {
// Single site target
sa := parseSiteArgument(target)
site, err := sa.LookupSite()
if err != nil || site == nil {
fmt.Printf("Error: Site '%s' not found.\n", sa.SiteName)
return
}
env, err := sa.LookupEnvironment(site.SiteID)
if err != nil || env == nil {
fmt.Printf("Error: Environment not found for '%s'.\n", target)
return
}
_, system, _, err := loadCaptainConfig()
if err != nil || system == nil {
fmt.Println("Error: Configuration file not found.")
return
}
envLower := strings.ToLower(env.Environment)
siteEnv := fmt.Sprintf("%s-%s", site.Site, envLower)
siteDir := fmt.Sprintf("%s_%d", site.Site, site.SiteID)
listPath := filepath.Join(system.Path, siteDir, envLower, listSubdir, "list.json")
if flagSkipIfRecent != "" && checkLastRun(listPath, flagSkipIfRecent) {
fmt.Printf("\n%s would be skipped (recent activity within %s)\n", siteEnv, flagSkipIfRecent)
fmt.Printf("\nenvironments skipped: 1\n")
fmt.Printf("environments to do: 0\n")
} else {
fmt.Println("\nEnvironments to process:")
fmt.Printf(" %s\n", siteEnv)
if flagSkipIfRecent != "" {
fmt.Printf("\nenvironments skipped: 0\n")
}
fmt.Printf("environments to do: 1\n")
}
}
}
// getB2SnapshotsPath returns the B2 snapshots bucket path and the folder prefix
// used for download authorization. Adjusts for fleet mode.
func getB2SnapshotsPath(captain *config.CaptainConfig, system *config.SystemConfig) (b2Snapshots, b2Folder string) {
if captain != nil {
b2Snapshots = captain.Remotes["b2_snapshots"]
}
// Derive from rclone_snapshot (e.g. "Remote:Bucket/Path" → "Bucket/Path") when b2_snapshots is not set
if b2Snapshots == "" && system != nil && system.RcloneSnapshot != "" {
if idx := strings.Index(system.RcloneSnapshot, ":"); idx >= 0 {
b2Snapshots = system.RcloneSnapshot[idx+1:]
}
}
if system != nil && system.CaptainCoreFleet == "true" {
b2Snapshots = b2Snapshots + "/" + captainID
}
idx := strings.Index(b2Snapshots, "/")
if idx >= 0 {
b2Folder = b2Snapshots[idx+1:]
}
return
}
// secondsToTimeString converts seconds into a human-readable duration string.
func secondsToTimeString(seconds int64) string {
if seconds < 60 {
return fmt.Sprintf("%d seconds", seconds)
}
if seconds < 3600 {
minutes := seconds / 60
secs := seconds % 60
return fmt.Sprintf("%d minutes and %d seconds", minutes, secs)
}
hours := seconds / 3600
minutes := (seconds % 3600) / 60
secs := seconds % 60
return fmt.Sprintf("%d hours, %d minutes and %d seconds", hours, minutes, secs)
}
// dirSize walks a directory tree and returns the total size of all files in bytes.
func dirSize(path string) (int64, error) {
var size int64
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
size += info.Size()
}
return nil
})
return size, err
}
// formatDateTimeHuman formats a time as "January 2nd 2006 3:04 pm" (matching PHP's 'F jS Y g:i a').
func formatDateTimeHuman(t time.Time) string {
day := t.Day()
suffix := "th"
if day%10 == 1 && day != 11 {
suffix = "st"
} else if day%10 == 2 && day != 12 {
suffix = "nd"
} else if day%10 == 3 && day != 13 {
suffix = "rd"
}
return fmt.Sprintf("%s %d%s %s", t.Format("January"), day, suffix, t.Format("2006 3:04 pm"))
}