feat: registry_api + fn sync — sincronización de registry.db entre PCs
Nuevo sistema para mantener datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) sincronizados entre múltiples máquinas via una API HTTP central desplegada en organic-machine.com. - Migración 011: tabla pc_locations (mapa de ubicaciones por PC) - registry/models.go: struct PcLocation - registry/store.go: CRUD PcLocation + helpers de sync - cmd/fn/sync.go: subcomando fn sync (push+pull, status, locations) - bash/functions/infra/setup_registry_api: pipeline de deploy Docker+Traefik - CLAUDE.md: documentación de sync y pc_locations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -43,6 +43,8 @@ func main() {
|
||||
cmdApp(os.Args[2:])
|
||||
case "analysis":
|
||||
cmdAnalysis(os.Args[2:])
|
||||
case "sync":
|
||||
cmdSync(os.Args[2:])
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
@@ -67,7 +69,8 @@ Usage:
|
||||
fn proposal <add|list|show|update> Gestiona proposals
|
||||
fn project <init|list|show|status> Gestiona proyectos
|
||||
fn app <list|clone|pull> Gestiona apps externas (Gitea)
|
||||
fn analysis <list|clone|pull> Gestiona analyses externas (Gitea)`)
|
||||
fn analysis <list|clone|pull> Gestiona analyses externas (Gitea)
|
||||
fn sync [status|locations] Sincroniza con servidor central`)
|
||||
}
|
||||
|
||||
func root() string {
|
||||
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"fn-registry/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAPIURL = "http://localhost:8420"
|
||||
pcIDFile = ".fn_pc"
|
||||
)
|
||||
|
||||
// syncRequest mirrors the server's SyncRequest.
|
||||
type syncRequest struct {
|
||||
PcID string `json:"pc_id"`
|
||||
Apps []registry.App `json:"apps"`
|
||||
Analysis []registry.Analysis `json:"analysis"`
|
||||
Projects []registry.Project `json:"projects"`
|
||||
Vaults []registry.Vault `json:"vaults"`
|
||||
Proposals []registry.Proposal `json:"proposals"`
|
||||
Locations []registry.PcLocation `json:"locations"`
|
||||
}
|
||||
|
||||
// syncResponse mirrors the server's SyncResponse.
|
||||
type syncResponse struct {
|
||||
Apps []registry.App `json:"apps"`
|
||||
Analysis []registry.Analysis `json:"analysis"`
|
||||
Projects []registry.Project `json:"projects"`
|
||||
Vaults []registry.Vault `json:"vaults"`
|
||||
Proposals []registry.Proposal `json:"proposals"`
|
||||
Locations []registry.PcLocation `json:"locations"`
|
||||
Stats struct {
|
||||
Received int `json:"received"`
|
||||
Updated int `json:"updated"`
|
||||
Sent int `json:"sent"`
|
||||
} `json:"stats"`
|
||||
}
|
||||
|
||||
func cmdSync(args []string) {
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "status":
|
||||
syncStatus()
|
||||
return
|
||||
case "locations":
|
||||
syncLocations()
|
||||
return
|
||||
case "push":
|
||||
syncPushPull()
|
||||
return
|
||||
case "help", "-h":
|
||||
printSyncUsage()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Default: full sync
|
||||
syncPushPull()
|
||||
}
|
||||
|
||||
func printSyncUsage() {
|
||||
fmt.Println(`fn sync — sincroniza registry.db con el servidor central
|
||||
|
||||
Usage:
|
||||
fn sync Push + pull (sync completo)
|
||||
fn sync status Muestra estado del PC actual
|
||||
fn sync locations Mapa de ubicaciones en todos los PCs
|
||||
|
||||
Config:
|
||||
~/.fn_pc Alias del PC (una linea, ej: "home-wsl")
|
||||
FN_REGISTRY_API URL del servidor (default: http://localhost:8420)
|
||||
REGISTRY_API_TOKEN Token de autenticacion (opcional)`)
|
||||
}
|
||||
|
||||
func syncPushPull() {
|
||||
pcID := readPcID()
|
||||
if pcID == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: ~/.fn_pc not found. Create it with: echo \"my-pc\" > ~/.fn_pc")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
apiBase, _, _ := parseAPIURL()
|
||||
fmt.Printf("syncing as %q against %s...\n", pcID, apiBase)
|
||||
|
||||
// 1. Collect local data
|
||||
apps, _ := db.AllApps()
|
||||
analysis, _ := db.AllAnalysis()
|
||||
projects, _ := db.ListAllProjects()
|
||||
vaults, _ := db.AllVaults()
|
||||
proposals, _ := db.AllProposals()
|
||||
|
||||
// 2. Scan local directories and build pc_locations
|
||||
locations := buildLocations(pcID, apps, analysis, projects, vaults)
|
||||
|
||||
// 3. Send to server
|
||||
req := syncRequest{
|
||||
PcID: pcID,
|
||||
Apps: apps,
|
||||
Analysis: analysis,
|
||||
Projects: projects,
|
||||
Vaults: vaults,
|
||||
Proposals: proposals,
|
||||
Locations: locations,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
apiBase, basicUser, basicPass := parseAPIURL()
|
||||
httpReq, err := http.NewRequest("POST", apiBase+"/api/sync", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// BasicAuth from URL (https://user:pass@host)
|
||||
if basicUser != "" {
|
||||
httpReq.SetBasicAuth(basicUser, basicPass)
|
||||
}
|
||||
|
||||
// App-level token
|
||||
token := os.Getenv("REGISTRY_API_TOKEN")
|
||||
if token != "" {
|
||||
httpReq.Header.Set("X-Registry-Token", token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot reach server: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Fprintf(os.Stderr, "error: server returned %d\n", resp.StatusCode)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var syncResp syncResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error decoding response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 4. Apply server data locally
|
||||
imported := applySync(db, syncResp)
|
||||
|
||||
fmt.Printf("done. sent %d items, server updated %d, received %d, imported %d locally\n",
|
||||
syncResp.Stats.Received, syncResp.Stats.Updated, syncResp.Stats.Sent, imported)
|
||||
}
|
||||
|
||||
// applySync writes server data into local registry.db (newer wins).
|
||||
func applySync(db *registry.DB, resp syncResponse) int {
|
||||
imported := 0
|
||||
|
||||
for _, a := range resp.Apps {
|
||||
existing, err := db.GetApp(a.ID)
|
||||
if err != nil || a.UpdatedAt.After(existing.UpdatedAt) {
|
||||
db.InsertApp(&a)
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range resp.Analysis {
|
||||
existing, err := db.GetAnalysis(a.ID)
|
||||
if err != nil || a.UpdatedAt.After(existing.UpdatedAt) {
|
||||
db.InsertAnalysis(&a)
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range resp.Projects {
|
||||
existing, err := db.GetProject(p.ID)
|
||||
if err != nil || p.UpdatedAt.After(existing.UpdatedAt) {
|
||||
db.InsertProject(&p)
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range resp.Vaults {
|
||||
existing, err := db.GetVault(v.ID)
|
||||
if err != nil || v.UpdatedAt.After(existing.UpdatedAt) {
|
||||
db.InsertVault(&v)
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range resp.Proposals {
|
||||
existing, err := db.GetProposal(p.ID)
|
||||
if err != nil || p.UpdatedAt.After(existing.UpdatedAt) {
|
||||
db.InsertProposal(&p)
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
// Locations: import all (server is authoritative)
|
||||
for _, loc := range resp.Locations {
|
||||
db.InsertPcLocation(&loc)
|
||||
imported++
|
||||
}
|
||||
|
||||
return imported
|
||||
}
|
||||
|
||||
// buildLocations scans local filesystem to detect which entities exist on this PC.
|
||||
func buildLocations(pcID string, apps []registry.App, analysis []registry.Analysis, projects []registry.Project, vaults []registry.Vault) []registry.PcLocation {
|
||||
r := root()
|
||||
now := time.Now().UTC()
|
||||
var locs []registry.PcLocation
|
||||
|
||||
for _, a := range apps {
|
||||
dirPath := a.DirPath
|
||||
if dirPath == "" {
|
||||
continue
|
||||
}
|
||||
absPath := filepath.Join(r, dirPath)
|
||||
status := "active"
|
||||
if _, err := os.Stat(absPath); err != nil {
|
||||
status = "missing"
|
||||
}
|
||||
locs = append(locs, registry.PcLocation{
|
||||
EntityType: "app",
|
||||
EntityID: a.ID,
|
||||
PcID: pcID,
|
||||
DirPath: absPath,
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
for _, a := range analysis {
|
||||
dirPath := a.DirPath
|
||||
if dirPath == "" {
|
||||
continue
|
||||
}
|
||||
absPath := filepath.Join(r, dirPath)
|
||||
status := "active"
|
||||
if _, err := os.Stat(absPath); err != nil {
|
||||
status = "missing"
|
||||
}
|
||||
locs = append(locs, registry.PcLocation{
|
||||
EntityType: "analysis",
|
||||
EntityID: a.ID,
|
||||
PcID: pcID,
|
||||
DirPath: absPath,
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
dirPath := p.DirPath
|
||||
if dirPath == "" {
|
||||
continue
|
||||
}
|
||||
absPath := filepath.Join(r, dirPath)
|
||||
status := "active"
|
||||
if _, err := os.Stat(absPath); err != nil {
|
||||
status = "missing"
|
||||
}
|
||||
locs = append(locs, registry.PcLocation{
|
||||
EntityType: "project",
|
||||
EntityID: p.ID,
|
||||
PcID: pcID,
|
||||
DirPath: absPath,
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
for _, v := range vaults {
|
||||
path := v.Path
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
status := "active"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
status = "missing"
|
||||
}
|
||||
locs = append(locs, registry.PcLocation{
|
||||
EntityType: "vault",
|
||||
EntityID: v.ID,
|
||||
PcID: pcID,
|
||||
DirPath: path,
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return locs
|
||||
}
|
||||
|
||||
func syncStatus() {
|
||||
pcID := readPcID()
|
||||
if pcID == "" {
|
||||
fmt.Println("PC: (not configured — create ~/.fn_pc)")
|
||||
} else {
|
||||
fmt.Printf("PC: %s\n", pcID)
|
||||
}
|
||||
base, _, _ := parseAPIURL()
|
||||
fmt.Printf("API: %s\n", base)
|
||||
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
apps, _ := db.AllApps()
|
||||
analysis, _ := db.AllAnalysis()
|
||||
projects, _ := db.ListAllProjects()
|
||||
vaults, _ := db.AllVaults()
|
||||
proposals, _ := db.AllProposals()
|
||||
locs, _ := db.ListAllPcLocations()
|
||||
|
||||
fmt.Printf("\nLocal registry:\n")
|
||||
fmt.Printf(" apps: %d\n", len(apps))
|
||||
fmt.Printf(" analysis: %d\n", len(analysis))
|
||||
fmt.Printf(" projects: %d\n", len(projects))
|
||||
fmt.Printf(" vaults: %d\n", len(vaults))
|
||||
fmt.Printf(" proposals: %d\n", len(proposals))
|
||||
fmt.Printf(" locations: %d\n", len(locs))
|
||||
|
||||
// Count by PC
|
||||
pcs := map[string]int{}
|
||||
for _, l := range locs {
|
||||
pcs[l.PcID]++
|
||||
}
|
||||
if len(pcs) > 0 {
|
||||
fmt.Printf("\nKnown PCs:\n")
|
||||
for pc, count := range pcs {
|
||||
marker := ""
|
||||
if pc == pcID {
|
||||
marker = " ← this"
|
||||
}
|
||||
fmt.Printf(" %-20s %d locations%s\n", pc, count, marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncLocations() {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
locs, _ := db.ListAllPcLocations()
|
||||
if len(locs) == 0 {
|
||||
fmt.Println("no locations registered. run 'fn sync' first.")
|
||||
return
|
||||
}
|
||||
|
||||
pcID := readPcID()
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "PC\tTYPE\tENTITY\tPATH\tSTATUS\n")
|
||||
for _, l := range locs {
|
||||
marker := ""
|
||||
if l.PcID == pcID {
|
||||
marker = "*"
|
||||
}
|
||||
fmt.Fprintf(w, "%s%s\t%s\t%s\t%s\t%s\n", l.PcID, marker, l.EntityType, l.EntityID, l.DirPath, l.Status)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func readPcID() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(home, pcIDFile))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func apiURL() string {
|
||||
if u := os.Getenv("FN_REGISTRY_API"); u != "" {
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
return defaultAPIURL
|
||||
}
|
||||
|
||||
// parseAPIURL extracts base URL and optional basicAuth credentials from FN_REGISTRY_API.
|
||||
// Supports: https://user:pass@host:port
|
||||
func parseAPIURL() (base, user, pass string) {
|
||||
raw := apiURL()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw, "", ""
|
||||
}
|
||||
if u.User != nil {
|
||||
user = u.User.Username()
|
||||
pass, _ = u.User.Password()
|
||||
u.User = nil
|
||||
return strings.TrimRight(u.String(), "/"), user, pass
|
||||
}
|
||||
return raw, "", ""
|
||||
}
|
||||
Reference in New Issue
Block a user