From 5626d99a17582ad07d0cd179b1994a857eeffef5 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 12:28:22 +0000 Subject: [PATCH] feat: add cmd/prod and cmd/monitor from go-devops Production infrastructure and security monitoring commands now live alongside the infra library they depend on. Co-Authored-By: Virgil --- cmd/monitor/cmd_commands.go | 47 +++ cmd/monitor/cmd_monitor.go | 586 ++++++++++++++++++++++++++++++++++++ cmd/prod/cmd_commands.go | 14 + cmd/prod/cmd_dns.go | 129 ++++++++ cmd/prod/cmd_lb.go | 113 +++++++ cmd/prod/cmd_prod.go | 35 +++ cmd/prod/cmd_setup.go | 284 +++++++++++++++++ cmd/prod/cmd_ssh.go | 63 ++++ cmd/prod/cmd_status.go | 324 ++++++++++++++++++++ go.mod | 37 +++ go.sum | 91 +++++- 11 files changed, 1722 insertions(+), 1 deletion(-) create mode 100644 cmd/monitor/cmd_commands.go create mode 100644 cmd/monitor/cmd_monitor.go create mode 100644 cmd/prod/cmd_commands.go create mode 100644 cmd/prod/cmd_dns.go create mode 100644 cmd/prod/cmd_lb.go create mode 100644 cmd/prod/cmd_prod.go create mode 100644 cmd/prod/cmd_setup.go create mode 100644 cmd/prod/cmd_ssh.go create mode 100644 cmd/prod/cmd_status.go diff --git a/cmd/monitor/cmd_commands.go b/cmd/monitor/cmd_commands.go new file mode 100644 index 0000000..b653bef --- /dev/null +++ b/cmd/monitor/cmd_commands.go @@ -0,0 +1,47 @@ +// Package monitor provides security monitoring commands. +// +// Commands: +// - monitor: Aggregate security findings from GitHub Security Tab, workflow artifacts, and PR comments +// +// Data sources (all free tier): +// - Code scanning: Semgrep, Trivy, Gitleaks, OSV-Scanner, Checkov, CodeQL +// - Dependabot: Dependency vulnerability alerts +// - Secret scanning: Exposed secrets/credentials +package monitor + +import ( + "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-i18n" +) + +func init() { + cli.RegisterCommands(AddMonitorCommands) +} + +// Style aliases from shared package +var ( + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + warningStyle = cli.WarningStyle + dimStyle = cli.DimStyle +) + +// AddMonitorCommands registers the 'monitor' command. +func AddMonitorCommands(root *cli.Command) { + monitorCmd := &cli.Command{ + Use: "monitor", + Short: i18n.T("cmd.monitor.short"), + Long: i18n.T("cmd.monitor.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runMonitor() + }, + } + + // Flags + monitorCmd.Flags().StringVarP(&monitorRepo, "repo", "r", "", i18n.T("cmd.monitor.flag.repo")) + monitorCmd.Flags().StringSliceVarP(&monitorSeverity, "severity", "s", []string{}, i18n.T("cmd.monitor.flag.severity")) + monitorCmd.Flags().BoolVar(&monitorJSON, "json", false, i18n.T("cmd.monitor.flag.json")) + monitorCmd.Flags().BoolVar(&monitorAll, "all", false, i18n.T("cmd.monitor.flag.all")) + + root.AddCommand(monitorCmd) +} diff --git a/cmd/monitor/cmd_monitor.go b/cmd/monitor/cmd_monitor.go new file mode 100644 index 0000000..9ce4ab2 --- /dev/null +++ b/cmd/monitor/cmd_monitor.go @@ -0,0 +1,586 @@ +// cmd_monitor.go implements the 'monitor' command for aggregating security findings. +// +// Usage: +// core monitor # Monitor current repo +// core monitor --repo X # Monitor specific repo +// core monitor --all # Monitor all repos in registry +// core monitor --severity high # Filter by severity +// core monitor --json # Output as JSON + +package monitor + +import ( + "cmp" + "encoding/json" + "fmt" + "maps" + "os/exec" + "slices" + "strings" + + "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-i18n" + "forge.lthn.ai/core/go-io" + "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/go-scm/repos" +) + +// Command flags +var ( + monitorRepo string + monitorSeverity []string + monitorJSON bool + monitorAll bool +) + +// Finding represents a security finding from any source +type Finding struct { + Source string `json:"source"` // semgrep, trivy, dependabot, secret-scanning, etc. + Severity string `json:"severity"` // critical, high, medium, low + Rule string `json:"rule"` // Rule ID or CVE + File string `json:"file"` // Affected file path + Line int `json:"line"` // Line number (0 if N/A) + Message string `json:"message"` // Description + URL string `json:"url"` // Link to finding + State string `json:"state"` // open, dismissed, fixed + RepoName string `json:"repo"` // Repository name + CreatedAt string `json:"created_at"` // When found + Labels []string `json:"suggested_labels,omitempty"` +} + +// CodeScanningAlert represents a GitHub code scanning alert +type CodeScanningAlert struct { + Number int `json:"number"` + State string `json:"state"` // open, dismissed, fixed + Rule struct { + ID string `json:"id"` + Severity string `json:"severity"` + Description string `json:"description"` + } `json:"rule"` + Tool struct { + Name string `json:"name"` + } `json:"tool"` + MostRecentInstance struct { + Location struct { + Path string `json:"path"` + StartLine int `json:"start_line"` + } `json:"location"` + Message struct { + Text string `json:"text"` + } `json:"message"` + } `json:"most_recent_instance"` + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` +} + +// DependabotAlert represents a GitHub Dependabot alert +type DependabotAlert struct { + Number int `json:"number"` + State string `json:"state"` // open, dismissed, fixed + SecurityVulnerability struct { + Severity string `json:"severity"` + Package struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` + } `json:"package"` + } `json:"security_vulnerability"` + SecurityAdvisory struct { + CVEID string `json:"cve_id"` + Summary string `json:"summary"` + Description string `json:"description"` + } `json:"security_advisory"` + Dependency struct { + ManifestPath string `json:"manifest_path"` + } `json:"dependency"` + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` +} + +// SecretScanningAlert represents a GitHub secret scanning alert +type SecretScanningAlert struct { + Number int `json:"number"` + State string `json:"state"` // open, resolved + SecretType string `json:"secret_type"` + Secret string `json:"secret"` // Partial, redacted + HTMLURL string `json:"html_url"` + LocationType string `json:"location_type"` + CreatedAt string `json:"created_at"` +} + +func runMonitor() error { + // Check gh is available + if _, err := exec.LookPath("gh"); err != nil { + return log.E("monitor", i18n.T("error.gh_not_found"), err) + } + + // Determine repos to scan + repoList, err := resolveRepos() + if err != nil { + return err + } + + if len(repoList) == 0 { + return log.E("monitor", i18n.T("cmd.monitor.error.no_repos"), nil) + } + + // Collect all findings and errors + var allFindings []Finding + var fetchErrors []string + for _, repo := range repoList { + if !monitorJSON { + cli.Print("\033[2K\r%s %s...", dimStyle.Render(i18n.T("cmd.monitor.scanning")), repo) + } + + findings, errs := fetchRepoFindings(repo) + allFindings = append(allFindings, findings...) + fetchErrors = append(fetchErrors, errs...) + } + + // Filter by severity if specified + if len(monitorSeverity) > 0 { + allFindings = filterBySeverity(allFindings, monitorSeverity) + } + + // Sort by severity (critical first) + sortBySeverity(allFindings) + + // Output + if monitorJSON { + return outputJSON(allFindings) + } + + cli.Print("\033[2K\r") // Clear scanning line + + // Show any fetch errors as warnings + if len(fetchErrors) > 0 { + for _, e := range fetchErrors { + cli.Print("%s %s\n", warningStyle.Render("!"), e) + } + cli.Blank() + } + + return outputTable(allFindings) +} + +// resolveRepos determines which repos to scan +func resolveRepos() ([]string, error) { + if monitorRepo != "" { + // Specific repo - if fully qualified (org/repo), use as-is + if strings.Contains(monitorRepo, "/") { + return []string{monitorRepo}, nil + } + // Otherwise, try to detect org from git remote, fallback to host-uk + // Note: Users outside host-uk org should use fully qualified names + org := detectOrgFromGit() + if org == "" { + org = "host-uk" + } + return []string{org + "/" + monitorRepo}, nil + } + + if monitorAll { + // All repos from registry + registry, err := repos.FindRegistry(io.Local) + if err != nil { + return nil, log.E("monitor", "failed to find registry", err) + } + + loaded, err := repos.LoadRegistry(io.Local, registry) + if err != nil { + return nil, log.E("monitor", "failed to load registry", err) + } + + var repoList []string + for _, r := range loaded.Repos { + repoList = append(repoList, loaded.Org+"/"+r.Name) + } + return repoList, nil + } + + // Default to current repo + repo, err := detectRepoFromGit() + if err != nil { + return nil, err + } + return []string{repo}, nil +} + +// fetchRepoFindings fetches all security findings for a repo +// Returns findings and any errors encountered (errors don't stop other fetches) +func fetchRepoFindings(repoFullName string) ([]Finding, []string) { + var findings []Finding + var errs []string + repoName := strings.Split(repoFullName, "/")[1] + + // Fetch code scanning alerts + codeFindings, err := fetchCodeScanningAlerts(repoFullName) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: code-scanning: %s", repoName, err)) + } + findings = append(findings, codeFindings...) + + // Fetch Dependabot alerts + depFindings, err := fetchDependabotAlerts(repoFullName) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: dependabot: %s", repoName, err)) + } + findings = append(findings, depFindings...) + + // Fetch secret scanning alerts + secretFindings, err := fetchSecretScanningAlerts(repoFullName) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: secret-scanning: %s", repoName, err)) + } + findings = append(findings, secretFindings...) + + return findings, errs +} + +// fetchCodeScanningAlerts fetches code scanning alerts +func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) { + args := []string{ + "api", + fmt.Sprintf("repos/%s/code-scanning/alerts", repoFullName), + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + // Check for expected "not enabled" responses vs actual errors + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + // These are expected conditions, not errors + if strings.Contains(stderr, "Advanced Security must be enabled") || + strings.Contains(stderr, "no analysis found") || + strings.Contains(stderr, "Not Found") { + return nil, nil + } + } + return nil, log.E("monitor.fetchCodeScanning", "API request failed", err) + } + + var alerts []CodeScanningAlert + if err := json.Unmarshal(output, &alerts); err != nil { + return nil, log.E("monitor.fetchCodeScanning", "failed to parse response", err) + } + + repoName := strings.Split(repoFullName, "/")[1] + var findings []Finding + for _, alert := range alerts { + if alert.State != "open" { + continue + } + f := Finding{ + Source: alert.Tool.Name, + Severity: normalizeSeverity(alert.Rule.Severity), + Rule: alert.Rule.ID, + File: alert.MostRecentInstance.Location.Path, + Line: alert.MostRecentInstance.Location.StartLine, + Message: alert.MostRecentInstance.Message.Text, + URL: alert.HTMLURL, + State: alert.State, + RepoName: repoName, + CreatedAt: alert.CreatedAt, + Labels: []string{"type:security"}, + } + if f.Message == "" { + f.Message = alert.Rule.Description + } + findings = append(findings, f) + } + + return findings, nil +} + +// fetchDependabotAlerts fetches Dependabot alerts +func fetchDependabotAlerts(repoFullName string) ([]Finding, error) { + args := []string{ + "api", + fmt.Sprintf("repos/%s/dependabot/alerts", repoFullName), + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + // Dependabot not enabled is expected + if strings.Contains(stderr, "Dependabot alerts are not enabled") || + strings.Contains(stderr, "Not Found") { + return nil, nil + } + } + return nil, log.E("monitor.fetchDependabot", "API request failed", err) + } + + var alerts []DependabotAlert + if err := json.Unmarshal(output, &alerts); err != nil { + return nil, log.E("monitor.fetchDependabot", "failed to parse response", err) + } + + repoName := strings.Split(repoFullName, "/")[1] + var findings []Finding + for _, alert := range alerts { + if alert.State != "open" { + continue + } + f := Finding{ + Source: "dependabot", + Severity: normalizeSeverity(alert.SecurityVulnerability.Severity), + Rule: alert.SecurityAdvisory.CVEID, + File: alert.Dependency.ManifestPath, + Line: 0, + Message: fmt.Sprintf("%s: %s", alert.SecurityVulnerability.Package.Name, alert.SecurityAdvisory.Summary), + URL: alert.HTMLURL, + State: alert.State, + RepoName: repoName, + CreatedAt: alert.CreatedAt, + Labels: []string{"type:security", "dependencies"}, + } + findings = append(findings, f) + } + + return findings, nil +} + +// fetchSecretScanningAlerts fetches secret scanning alerts +func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) { + args := []string{ + "api", + fmt.Sprintf("repos/%s/secret-scanning/alerts", repoFullName), + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + // Secret scanning not enabled is expected + if strings.Contains(stderr, "Secret scanning is disabled") || + strings.Contains(stderr, "Not Found") { + return nil, nil + } + } + return nil, log.E("monitor.fetchSecretScanning", "API request failed", err) + } + + var alerts []SecretScanningAlert + if err := json.Unmarshal(output, &alerts); err != nil { + return nil, log.E("monitor.fetchSecretScanning", "failed to parse response", err) + } + + repoName := strings.Split(repoFullName, "/")[1] + var findings []Finding + for _, alert := range alerts { + if alert.State != "open" { + continue + } + f := Finding{ + Source: "secret-scanning", + Severity: "critical", // Secrets are always critical + Rule: alert.SecretType, + File: alert.LocationType, + Line: 0, + Message: fmt.Sprintf("Exposed %s detected", alert.SecretType), + URL: alert.HTMLURL, + State: alert.State, + RepoName: repoName, + CreatedAt: alert.CreatedAt, + Labels: []string{"type:security", "secrets"}, + } + findings = append(findings, f) + } + + return findings, nil +} + +// normalizeSeverity normalizes severity strings to standard values +func normalizeSeverity(s string) string { + s = strings.ToLower(s) + switch s { + case "critical", "crit": + return "critical" + case "high", "error": + return "high" + case "medium", "moderate", "warning": + return "medium" + case "low", "info", "note": + return "low" + default: + return "medium" + } +} + +// filterBySeverity filters findings by severity +func filterBySeverity(findings []Finding, severities []string) []Finding { + sevSet := make(map[string]bool) + for _, s := range severities { + sevSet[strings.ToLower(s)] = true + } + + var filtered []Finding + for _, f := range findings { + if sevSet[f.Severity] { + filtered = append(filtered, f) + } + } + return filtered +} + +// sortBySeverity sorts findings by severity (critical first) +func sortBySeverity(findings []Finding) { + severityOrder := map[string]int{ + "critical": 0, + "high": 1, + "medium": 2, + "low": 3, + } + + slices.SortFunc(findings, func(a, b Finding) int { + return cmp.Or( + cmp.Compare(severityOrder[a.Severity], severityOrder[b.Severity]), + cmp.Compare(a.RepoName, b.RepoName), + ) + }) +} + +// outputJSON outputs findings as JSON +func outputJSON(findings []Finding) error { + data, err := json.MarshalIndent(findings, "", " ") + if err != nil { + return log.E("monitor", "failed to marshal findings", err) + } + cli.Print("%s\n", string(data)) + return nil +} + +// outputTable outputs findings as a formatted table +func outputTable(findings []Finding) error { + if len(findings) == 0 { + cli.Print("%s\n", successStyle.Render(i18n.T("cmd.monitor.no_findings"))) + return nil + } + + // Count by severity + counts := make(map[string]int) + for _, f := range findings { + counts[f.Severity]++ + } + + // Header summary + var parts []string + if counts["critical"] > 0 { + parts = append(parts, errorStyle.Render(fmt.Sprintf("%d critical", counts["critical"]))) + } + if counts["high"] > 0 { + parts = append(parts, errorStyle.Render(fmt.Sprintf("%d high", counts["high"]))) + } + if counts["medium"] > 0 { + parts = append(parts, warningStyle.Render(fmt.Sprintf("%d medium", counts["medium"]))) + } + if counts["low"] > 0 { + parts = append(parts, dimStyle.Render(fmt.Sprintf("%d low", counts["low"]))) + } + cli.Print("%s: %s\n", i18n.T("cmd.monitor.found"), strings.Join(parts, ", ")) + cli.Blank() + + // Group by repo + byRepo := make(map[string][]Finding) + for _, f := range findings { + byRepo[f.RepoName] = append(byRepo[f.RepoName], f) + } + + // Sort repos for consistent output + repoNames := slices.Sorted(maps.Keys(byRepo)) + + // Print by repo + for _, repo := range repoNames { + repoFindings := byRepo[repo] + cli.Print("%s\n", cli.BoldStyle.Render(repo)) + for _, f := range repoFindings { + sevStyle := dimStyle + switch f.Severity { + case "critical", "high": + sevStyle = errorStyle + case "medium": + sevStyle = warningStyle + } + + // Format: [severity] source: message (file:line) + location := "" + if f.File != "" { + location = f.File + if f.Line > 0 { + location = fmt.Sprintf("%s:%d", f.File, f.Line) + } + } + + cli.Print(" %s %s: %s", + sevStyle.Render(fmt.Sprintf("[%s]", f.Severity)), + dimStyle.Render(f.Source), + truncate(f.Message, 60)) + if location != "" { + cli.Print(" %s", dimStyle.Render("("+location+")")) + } + cli.Blank() + } + cli.Blank() + } + + return nil +} + +// truncate truncates a string to max runes (Unicode-safe) +func truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-3]) + "..." +} + +// detectRepoFromGit detects the repo from git remote +func detectRepoFromGit() (string, error) { + cmd := exec.Command("git", "remote", "get-url", "origin") + output, err := cmd.Output() + if err != nil { + return "", log.E("monitor", i18n.T("cmd.monitor.error.not_git_repo"), err) + } + + url := strings.TrimSpace(string(output)) + return parseGitHubRepo(url) +} + +// detectOrgFromGit tries to detect the org from git remote +func detectOrgFromGit() string { + repo, err := detectRepoFromGit() + if err != nil { + return "" + } + parts := strings.Split(repo, "/") + if len(parts) >= 1 { + return parts[0] + } + return "" +} + +// parseGitHubRepo extracts org/repo from a git URL +func parseGitHubRepo(url string) (string, error) { + // Handle SSH URLs: git@github.com:org/repo.git + if strings.HasPrefix(url, "git@github.com:") { + path := strings.TrimPrefix(url, "git@github.com:") + path = strings.TrimSuffix(path, ".git") + return path, nil + } + + // Handle HTTPS URLs: https://github.com/org/repo.git + if strings.Contains(url, "github.com/") { + parts := strings.Split(url, "github.com/") + if len(parts) >= 2 { + path := strings.TrimSuffix(parts[1], ".git") + return path, nil + } + } + + return "", fmt.Errorf("could not parse GitHub repo from URL: %s", url) +} diff --git a/cmd/prod/cmd_commands.go b/cmd/prod/cmd_commands.go new file mode 100644 index 0000000..1ecb3c8 --- /dev/null +++ b/cmd/prod/cmd_commands.go @@ -0,0 +1,14 @@ +package prod + +import ( + "forge.lthn.ai/core/cli/pkg/cli" +) + +func init() { + cli.RegisterCommands(AddProdCommands) +} + +// AddProdCommands registers the 'prod' command and all subcommands. +func AddProdCommands(root *cli.Command) { + root.AddCommand(Cmd) +} diff --git a/cmd/prod/cmd_dns.go b/cmd/prod/cmd_dns.go new file mode 100644 index 0000000..4f362d4 --- /dev/null +++ b/cmd/prod/cmd_dns.go @@ -0,0 +1,129 @@ +package prod + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-infra" +) + +var dnsCmd = &cli.Command{ + Use: "dns", + Short: "Manage DNS records via CloudNS", + Long: `View and manage DNS records for host.uk.com via CloudNS API. + +Requires: + CLOUDNS_AUTH_ID CloudNS auth ID + CLOUDNS_AUTH_PASSWORD CloudNS auth password`, +} + +var dnsListCmd = &cli.Command{ + Use: "list [zone]", + Short: "List DNS records", + Args: cli.MaximumNArgs(1), + RunE: runDNSList, +} + +var dnsSetCmd = &cli.Command{ + Use: "set ", + Short: "Create or update a DNS record", + Long: `Create or update a DNS record. Example: + core prod dns set hermes.lb A 1.2.3.4 + core prod dns set "*.host.uk.com" CNAME hermes.lb.host.uk.com`, + Args: cli.ExactArgs(3), + RunE: runDNSSet, +} + +var ( + dnsZone string + dnsTTL int +) + +func init() { + dnsCmd.PersistentFlags().StringVar(&dnsZone, "zone", "host.uk.com", "DNS zone") + + dnsSetCmd.Flags().IntVar(&dnsTTL, "ttl", 300, "Record TTL in seconds") + + dnsCmd.AddCommand(dnsListCmd) + dnsCmd.AddCommand(dnsSetCmd) +} + +func getDNSClient() (*infra.CloudNSClient, error) { + authID := os.Getenv("CLOUDNS_AUTH_ID") + authPass := os.Getenv("CLOUDNS_AUTH_PASSWORD") + if authID == "" || authPass == "" { + return nil, errors.New("CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD required") + } + return infra.NewCloudNSClient(authID, authPass), nil +} + +func runDNSList(cmd *cli.Command, args []string) error { + dns, err := getDNSClient() + if err != nil { + return err + } + + zone := dnsZone + if len(args) > 0 { + zone = args[0] + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + records, err := dns.ListRecords(ctx, zone) + if err != nil { + return fmt.Errorf("list records: %w", err) + } + + cli.Print("%s DNS records for %s\n\n", cli.BoldStyle.Render("▶"), cli.TitleStyle.Render(zone)) + + if len(records) == 0 { + cli.Print(" No records found\n") + return nil + } + + for id, r := range records { + cli.Print(" %s %-6s %-30s %s TTL:%s\n", + cli.DimStyle.Render(id), + cli.BoldStyle.Render(r.Type), + r.Host, + r.Record, + r.TTL) + } + + return nil +} + +func runDNSSet(cmd *cli.Command, args []string) error { + dns, err := getDNSClient() + if err != nil { + return err + } + + host := args[0] + recordType := args[1] + value := args[2] + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + changed, err := dns.EnsureRecord(ctx, dnsZone, host, recordType, value, dnsTTL) + if err != nil { + return fmt.Errorf("set record: %w", err) + } + + if changed { + cli.Print("%s %s %s %s -> %s\n", + cli.SuccessStyle.Render("✓"), + recordType, host, dnsZone, value) + } else { + cli.Print("%s Record already correct\n", cli.DimStyle.Render("·")) + } + + return nil +} diff --git a/cmd/prod/cmd_lb.go b/cmd/prod/cmd_lb.go new file mode 100644 index 0000000..0c6c935 --- /dev/null +++ b/cmd/prod/cmd_lb.go @@ -0,0 +1,113 @@ +package prod + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-infra" +) + +var lbCmd = &cli.Command{ + Use: "lb", + Short: "Manage Hetzner load balancer", + Long: `View and manage the Hetzner Cloud managed load balancer. + +Requires: HCLOUD_TOKEN`, +} + +var lbStatusCmd = &cli.Command{ + Use: "status", + Short: "Show load balancer status and target health", + RunE: runLBStatus, +} + +var lbCreateCmd = &cli.Command{ + Use: "create", + Short: "Create load balancer from infra.yaml", + RunE: runLBCreate, +} + +func init() { + lbCmd.AddCommand(lbStatusCmd) + lbCmd.AddCommand(lbCreateCmd) +} + +func getHCloudClient() (*infra.HCloudClient, error) { + token := os.Getenv("HCLOUD_TOKEN") + if token == "" { + return nil, errors.New("HCLOUD_TOKEN environment variable required") + } + return infra.NewHCloudClient(token), nil +} + +func runLBStatus(cmd *cli.Command, args []string) error { + hc, err := getHCloudClient() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + lbs, err := hc.ListLoadBalancers(ctx) + if err != nil { + return fmt.Errorf("list load balancers: %w", err) + } + + if len(lbs) == 0 { + cli.Print("No load balancers found\n") + return nil + } + + for _, lb := range lbs { + cli.Print("%s %s\n", cli.BoldStyle.Render("▶"), cli.TitleStyle.Render(lb.Name)) + cli.Print(" ID: %d\n", lb.ID) + cli.Print(" IP: %s\n", lb.PublicNet.IPv4.IP) + cli.Print(" Algorithm: %s\n", lb.Algorithm.Type) + cli.Print(" Location: %s\n", lb.Location.Name) + + if len(lb.Services) > 0 { + cli.Print("\n Services:\n") + for _, s := range lb.Services { + cli.Print(" %s :%d -> :%d proxy_protocol=%v\n", + s.Protocol, s.ListenPort, s.DestinationPort, s.Proxyprotocol) + } + } + + if len(lb.Targets) > 0 { + cli.Print("\n Targets:\n") + for _, t := range lb.Targets { + ip := "" + if t.IP != nil { + ip = t.IP.IP + } + for _, hs := range t.HealthStatus { + icon := cli.SuccessStyle.Render("●") + if hs.Status != "healthy" { + icon = cli.ErrorStyle.Render("○") + } + cli.Print(" %s %s :%d %s\n", icon, ip, hs.ListenPort, hs.Status) + } + } + } + fmt.Println() + } + + return nil +} + +func runLBCreate(cmd *cli.Command, args []string) error { + cfg, _, err := loadConfig() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + return stepLoadBalancer(ctx, cfg) +} diff --git a/cmd/prod/cmd_prod.go b/cmd/prod/cmd_prod.go new file mode 100644 index 0000000..f00b364 --- /dev/null +++ b/cmd/prod/cmd_prod.go @@ -0,0 +1,35 @@ +package prod + +import ( + "forge.lthn.ai/core/cli/pkg/cli" +) + +var ( + infraFile string +) + +// Cmd is the root prod command. +var Cmd = &cli.Command{ + Use: "prod", + Short: "Production infrastructure management", + Long: `Manage the Host UK production infrastructure. + +Commands: + status Show infrastructure health and connectivity + setup Phase 1: discover topology, create LB, configure DNS + dns Manage DNS records via CloudNS + lb Manage Hetzner load balancer + ssh SSH into a production host + +Configuration is read from infra.yaml in the project root.`, +} + +func init() { + Cmd.PersistentFlags().StringVar(&infraFile, "config", "", "Path to infra.yaml (auto-discovered if not set)") + + Cmd.AddCommand(statusCmd) + Cmd.AddCommand(setupCmd) + Cmd.AddCommand(dnsCmd) + Cmd.AddCommand(lbCmd) + Cmd.AddCommand(sshCmd) +} diff --git a/cmd/prod/cmd_setup.go b/cmd/prod/cmd_setup.go new file mode 100644 index 0000000..3e54442 --- /dev/null +++ b/cmd/prod/cmd_setup.go @@ -0,0 +1,284 @@ +package prod + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-infra" +) + +var setupCmd = &cli.Command{ + Use: "setup", + Short: "Phase 1: discover topology, create LB, configure DNS", + Long: `Run the Phase 1 foundation setup: + + 1. Discover Hetzner topology (Cloud + Robot servers) + 2. Create Hetzner managed load balancer + 3. Configure DNS records via CloudNS + 4. Verify connectivity to all hosts + +Required environment variables: + HCLOUD_TOKEN Hetzner Cloud API token + HETZNER_ROBOT_USER Hetzner Robot username + HETZNER_ROBOT_PASS Hetzner Robot password + CLOUDNS_AUTH_ID CloudNS auth ID + CLOUDNS_AUTH_PASSWORD CloudNS auth password`, + RunE: runSetup, +} + +var ( + setupDryRun bool + setupStep string +) + +func init() { + setupCmd.Flags().BoolVar(&setupDryRun, "dry-run", false, "Show what would be done without making changes") + setupCmd.Flags().StringVar(&setupStep, "step", "", "Run a specific step only (discover, lb, dns)") +} + +func runSetup(cmd *cli.Command, args []string) error { + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + + cli.Print("%s Production setup from %s\n\n", + cli.BoldStyle.Render("▶"), + cli.DimStyle.Render(cfgPath)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + steps := []struct { + name string + fn func(context.Context, *infra.Config) error + }{ + {"discover", stepDiscover}, + {"lb", stepLoadBalancer}, + {"dns", stepDNS}, + } + + for _, step := range steps { + if setupStep != "" && setupStep != step.name { + continue + } + + cli.Print("\n%s Step: %s\n", cli.BoldStyle.Render("━━"), cli.TitleStyle.Render(step.name)) + + if err := step.fn(ctx, cfg); err != nil { + cli.Print(" %s %s: %s\n", cli.ErrorStyle.Render("✗"), step.name, err) + return fmt.Errorf("step %s failed: %w", step.name, err) + } + + cli.Print(" %s %s complete\n", cli.SuccessStyle.Render("✓"), step.name) + } + + cli.Print("\n%s Setup complete\n", cli.SuccessStyle.Render("✓")) + return nil +} + +func stepDiscover(ctx context.Context, cfg *infra.Config) error { + // Discover HCloud servers + hcloudToken := os.Getenv("HCLOUD_TOKEN") + if hcloudToken != "" { + cli.Print(" Discovering Hetzner Cloud servers...\n") + + hc := infra.NewHCloudClient(hcloudToken) + servers, err := hc.ListServers(ctx) + if err != nil { + return fmt.Errorf("list HCloud servers: %w", err) + } + + for _, s := range servers { + cli.Print(" %s %s %s %s %s\n", + cli.SuccessStyle.Render("●"), + cli.BoldStyle.Render(s.Name), + s.PublicNet.IPv4.IP, + s.ServerType.Name, + cli.DimStyle.Render(s.Datacenter.Name)) + } + } else { + cli.Print(" %s HCLOUD_TOKEN not set — skipping Cloud discovery\n", + cli.WarningStyle.Render("⚠")) + } + + // Discover Robot servers + robotUser := os.Getenv("HETZNER_ROBOT_USER") + robotPass := os.Getenv("HETZNER_ROBOT_PASS") + if robotUser != "" && robotPass != "" { + cli.Print(" Discovering Hetzner Robot servers...\n") + + hr := infra.NewHRobotClient(robotUser, robotPass) + servers, err := hr.ListServers(ctx) + if err != nil { + return fmt.Errorf("list Robot servers: %w", err) + } + + for _, s := range servers { + status := cli.SuccessStyle.Render("●") + if s.Status != "ready" { + status = cli.WarningStyle.Render("○") + } + cli.Print(" %s %s %s %s %s\n", + status, + cli.BoldStyle.Render(s.ServerName), + s.ServerIP, + s.Product, + cli.DimStyle.Render(s.Datacenter)) + } + } else { + cli.Print(" %s HETZNER_ROBOT_USER/PASS not set — skipping Robot discovery\n", + cli.WarningStyle.Render("⚠")) + } + + return nil +} + +func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error { + hcloudToken := os.Getenv("HCLOUD_TOKEN") + if hcloudToken == "" { + return errors.New("HCLOUD_TOKEN required for load balancer management") + } + + hc := infra.NewHCloudClient(hcloudToken) + + // Check if LB already exists + lbs, err := hc.ListLoadBalancers(ctx) + if err != nil { + return fmt.Errorf("list load balancers: %w", err) + } + + for _, lb := range lbs { + if lb.Name == cfg.LoadBalancer.Name { + cli.Print(" Load balancer '%s' already exists (ID: %d, IP: %s)\n", + lb.Name, lb.ID, lb.PublicNet.IPv4.IP) + return nil + } + } + + if setupDryRun { + cli.Print(" [dry-run] Would create load balancer '%s' (%s) in %s\n", + cfg.LoadBalancer.Name, cfg.LoadBalancer.Type, cfg.LoadBalancer.Location) + for _, b := range cfg.LoadBalancer.Backends { + if host, ok := cfg.Hosts[b.Host]; ok { + cli.Print(" [dry-run] Backend: %s (%s:%d)\n", b.Host, host.IP, b.Port) + } + } + return nil + } + + // Build targets from config + targets := make([]infra.HCloudLBCreateTarget, 0, len(cfg.LoadBalancer.Backends)) + for _, b := range cfg.LoadBalancer.Backends { + host, ok := cfg.Hosts[b.Host] + if !ok { + return fmt.Errorf("backend host '%s' not found in config", b.Host) + } + targets = append(targets, infra.HCloudLBCreateTarget{ + Type: "ip", + IP: &infra.HCloudLBTargetIP{IP: host.IP}, + }) + } + + // Build services + services := make([]infra.HCloudLBService, 0, len(cfg.LoadBalancer.Listeners)) + for _, l := range cfg.LoadBalancer.Listeners { + svc := infra.HCloudLBService{ + Protocol: l.Protocol, + ListenPort: l.Frontend, + DestinationPort: l.Backend, + Proxyprotocol: l.ProxyProtocol, + HealthCheck: &infra.HCloudLBHealthCheck{ + Protocol: cfg.LoadBalancer.Health.Protocol, + Port: l.Backend, + Interval: cfg.LoadBalancer.Health.Interval, + Timeout: 10, + Retries: 3, + HTTP: &infra.HCloudLBHCHTTP{ + Path: cfg.LoadBalancer.Health.Path, + StatusCode: "2??", + }, + }, + } + services = append(services, svc) + } + + req := infra.HCloudLBCreateRequest{ + Name: cfg.LoadBalancer.Name, + LoadBalancerType: cfg.LoadBalancer.Type, + Location: cfg.LoadBalancer.Location, + Algorithm: infra.HCloudLBAlgorithm{Type: cfg.LoadBalancer.Algorithm}, + Services: services, + Targets: targets, + Labels: map[string]string{ + "project": "host-uk", + "managed": "core-cli", + }, + } + + cli.Print(" Creating load balancer '%s'...\n", cfg.LoadBalancer.Name) + + lb, err := hc.CreateLoadBalancer(ctx, req) + if err != nil { + return fmt.Errorf("create load balancer: %w", err) + } + + cli.Print(" Created: %s (ID: %d, IP: %s)\n", + cli.BoldStyle.Render(lb.Name), lb.ID, lb.PublicNet.IPv4.IP) + + return nil +} + +func stepDNS(ctx context.Context, cfg *infra.Config) error { + authID := os.Getenv("CLOUDNS_AUTH_ID") + authPass := os.Getenv("CLOUDNS_AUTH_PASSWORD") + if authID == "" || authPass == "" { + return errors.New("CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD required") + } + + dns := infra.NewCloudNSClient(authID, authPass) + + for zoneName, zone := range cfg.DNS.Zones { + cli.Print(" Zone: %s\n", cli.BoldStyle.Render(zoneName)) + + for _, rec := range zone.Records { + value := rec.Value + // Skip templated values (need LB IP first) + if value == "{{.lb_ip}}" { + cli.Print(" %s %s %s %s — %s\n", + cli.WarningStyle.Render("⚠"), + rec.Name, rec.Type, value, + cli.DimStyle.Render("needs LB IP (run setup --step=lb first)")) + continue + } + + if setupDryRun { + cli.Print(" [dry-run] %s %s -> %s (TTL: %d)\n", + rec.Type, rec.Name, value, rec.TTL) + continue + } + + changed, err := dns.EnsureRecord(ctx, zoneName, rec.Name, rec.Type, value, rec.TTL) + if err != nil { + cli.Print(" %s %s %s: %s\n", cli.ErrorStyle.Render("✗"), rec.Type, rec.Name, err) + continue + } + + if changed { + cli.Print(" %s %s %s -> %s\n", + cli.SuccessStyle.Render("✓"), + rec.Type, rec.Name, value) + } else { + cli.Print(" %s %s %s (no change)\n", + cli.DimStyle.Render("·"), + rec.Type, rec.Name) + } + } + } + + return nil +} diff --git a/cmd/prod/cmd_ssh.go b/cmd/prod/cmd_ssh.go new file mode 100644 index 0000000..7760836 --- /dev/null +++ b/cmd/prod/cmd_ssh.go @@ -0,0 +1,63 @@ +package prod + +import ( + "fmt" + "os" + "os/exec" + "syscall" + + "forge.lthn.ai/core/cli/pkg/cli" +) + +var sshCmd = &cli.Command{ + Use: "ssh ", + Short: "SSH into a production host", + Long: `Open an SSH session to a production host defined in infra.yaml. + +Examples: + core prod ssh noc + core prod ssh de + core prod ssh de2 + core prod ssh build`, + Args: cli.ExactArgs(1), + RunE: runSSH, +} + +func runSSH(cmd *cli.Command, args []string) error { + cfg, _, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + host, ok := cfg.Hosts[name] + if !ok { + // List available hosts + cli.Print("Unknown host '%s'. Available:\n", name) + for n, h := range cfg.Hosts { + cli.Print(" %s %s (%s)\n", cli.BoldStyle.Render(n), h.IP, h.Role) + } + return fmt.Errorf("host '%s' not found in infra.yaml", name) + } + + sshArgs := []string{ + "ssh", + "-i", host.SSH.Key, + "-p", fmt.Sprintf("%d", host.SSH.Port), + "-o", "StrictHostKeyChecking=accept-new", + fmt.Sprintf("%s@%s", host.SSH.User, host.IP), + } + + cli.Print("%s %s@%s (%s)\n", + cli.BoldStyle.Render("▶"), + host.SSH.User, host.FQDN, + cli.DimStyle.Render(host.IP)) + + sshPath, err := exec.LookPath("ssh") + if err != nil { + return fmt.Errorf("ssh not found: %w", err) + } + + // Replace current process with SSH + return syscall.Exec(sshPath, sshArgs, os.Environ()) +} diff --git a/cmd/prod/cmd_status.go b/cmd/prod/cmd_status.go new file mode 100644 index 0000000..b69b66c --- /dev/null +++ b/cmd/prod/cmd_status.go @@ -0,0 +1,324 @@ +package prod + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "time" + + "forge.lthn.ai/core/go-ansible" + "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-infra" +) + +var statusCmd = &cli.Command{ + Use: "status", + Short: "Show production infrastructure health", + Long: `Check connectivity, services, and cluster health across all production hosts. + +Tests: + - SSH connectivity to all hosts + - Docker daemon status + - Coolify controller (noc) + - Galera cluster state (de, de2) + - Redis Sentinel status (de, de2) + - Load balancer health (if HCLOUD_TOKEN set)`, + RunE: runStatus, +} + +type hostStatus struct { + Name string + Host *infra.Host + Connected bool + ConnTime time.Duration + OS string + Docker string + Services map[string]string + Error error +} + +func runStatus(cmd *cli.Command, args []string) error { + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + + cli.Print("%s Infrastructure status from %s\n\n", + cli.BoldStyle.Render("▶"), + cli.DimStyle.Render(cfgPath)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Check all hosts in parallel + var ( + wg sync.WaitGroup + mu sync.Mutex + statuses []hostStatus + ) + + for name, host := range cfg.Hosts { + wg.Add(1) + go func(name string, host *infra.Host) { + defer wg.Done() + s := checkHost(ctx, name, host) + mu.Lock() + statuses = append(statuses, s) + mu.Unlock() + }(name, host) + } + + wg.Wait() + + // Print results in consistent order + order := []string{"noc", "de", "de2", "build"} + for _, name := range order { + for _, s := range statuses { + if s.Name == name { + printHostStatus(s) + break + } + } + } + + // Check LB if token available + if token := os.Getenv("HCLOUD_TOKEN"); token != "" { + fmt.Println() + checkLoadBalancer(ctx, token) + } else { + fmt.Println() + cli.Print("%s Load balancer: %s\n", + cli.DimStyle.Render(" ○"), + cli.DimStyle.Render("HCLOUD_TOKEN not set (skipped)")) + } + + return nil +} + +func checkHost(ctx context.Context, name string, host *infra.Host) hostStatus { + s := hostStatus{ + Name: name, + Host: host, + Services: make(map[string]string), + } + + sshCfg := ansible.SSHConfig{ + Host: host.IP, + Port: host.SSH.Port, + User: host.SSH.User, + KeyFile: host.SSH.Key, + Timeout: 15 * time.Second, + } + + client, err := ansible.NewSSHClient(sshCfg) + if err != nil { + s.Error = fmt.Errorf("create SSH client: %w", err) + return s + } + defer func() { _ = client.Close() }() + + start := time.Now() + if err := client.Connect(ctx); err != nil { + s.Error = fmt.Errorf("SSH connect: %w", err) + return s + } + s.Connected = true + s.ConnTime = time.Since(start) + + // OS info + stdout, _, _, _ := client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2") + s.OS = strings.TrimSpace(stdout) + + // Docker + stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null | head -1") + if err == nil && stdout != "" { + s.Docker = strings.TrimSpace(stdout) + } + + // Check each expected service + for _, svc := range host.Services { + status := checkService(ctx, client, svc) + s.Services[svc] = status + } + + return s +} + +func checkService(ctx context.Context, client *ansible.SSHClient, service string) string { + switch service { + case "coolify": + stdout, _, _, _ := client.Run(ctx, "docker ps --format '{{.Names}}' 2>/dev/null | grep -c coolify") + if strings.TrimSpace(stdout) != "0" && strings.TrimSpace(stdout) != "" { + return "running" + } + return "not running" + + case "traefik": + stdout, _, _, _ := client.Run(ctx, "docker ps --format '{{.Names}}' 2>/dev/null | grep -c traefik") + if strings.TrimSpace(stdout) != "0" && strings.TrimSpace(stdout) != "" { + return "running" + } + return "not running" + + case "galera": + // Check Galera cluster state + stdout, _, _, _ := client.Run(ctx, + "docker exec $(docker ps -q --filter name=mariadb 2>/dev/null || echo none) "+ + "mariadb -u root -e \"SHOW STATUS LIKE 'wsrep_cluster_size'\" --skip-column-names 2>/dev/null | awk '{print $2}'") + size := strings.TrimSpace(stdout) + if size != "" && size != "0" { + return fmt.Sprintf("cluster_size=%s", size) + } + // Try non-Docker + stdout, _, _, _ = client.Run(ctx, + "mariadb -u root -e \"SHOW STATUS LIKE 'wsrep_cluster_size'\" --skip-column-names 2>/dev/null | awk '{print $2}'") + size = strings.TrimSpace(stdout) + if size != "" && size != "0" { + return fmt.Sprintf("cluster_size=%s", size) + } + return "not running" + + case "redis": + stdout, _, _, _ := client.Run(ctx, + "docker exec $(docker ps -q --filter name=redis 2>/dev/null || echo none) "+ + "redis-cli ping 2>/dev/null") + if strings.TrimSpace(stdout) == "PONG" { + return "running" + } + stdout, _, _, _ = client.Run(ctx, "redis-cli ping 2>/dev/null") + if strings.TrimSpace(stdout) == "PONG" { + return "running" + } + return "not running" + + case "forgejo-runner": + stdout, _, _, _ := client.Run(ctx, "systemctl is-active forgejo-runner 2>/dev/null || docker ps --format '{{.Names}}' 2>/dev/null | grep -c runner") + val := strings.TrimSpace(stdout) + if val == "active" || (val != "0" && val != "") { + return "running" + } + return "not running" + + default: + // Generic docker container check + stdout, _, _, _ := client.Run(ctx, + fmt.Sprintf("docker ps --format '{{.Names}}' 2>/dev/null | grep -c %s", service)) + if strings.TrimSpace(stdout) != "0" && strings.TrimSpace(stdout) != "" { + return "running" + } + return "not running" + } +} + +func printHostStatus(s hostStatus) { + // Host header + roleStyle := cli.DimStyle + switch s.Host.Role { + case "app": + roleStyle = cli.SuccessStyle + case "bastion": + roleStyle = cli.WarningStyle + case "builder": + roleStyle = cli.InfoStyle + } + + cli.Print(" %s %s %s %s\n", + cli.BoldStyle.Render(s.Name), + cli.DimStyle.Render(s.Host.IP), + roleStyle.Render(s.Host.Role), + cli.DimStyle.Render(s.Host.FQDN)) + + if s.Error != nil { + cli.Print(" %s %s\n", cli.ErrorStyle.Render("✗"), s.Error) + return + } + + if !s.Connected { + cli.Print(" %s SSH unreachable\n", cli.ErrorStyle.Render("✗")) + return + } + + // Connection info + cli.Print(" %s SSH %s", + cli.SuccessStyle.Render("✓"), + cli.DimStyle.Render(s.ConnTime.Round(time.Millisecond).String())) + if s.OS != "" { + cli.Print(" %s", cli.DimStyle.Render(s.OS)) + } + fmt.Println() + + if s.Docker != "" { + cli.Print(" %s %s\n", cli.SuccessStyle.Render("✓"), cli.DimStyle.Render(s.Docker)) + } + + // Services + for _, svc := range s.Host.Services { + status, ok := s.Services[svc] + if !ok { + continue + } + + icon := cli.SuccessStyle.Render("●") + style := cli.SuccessStyle + if status == "not running" { + icon = cli.ErrorStyle.Render("○") + style = cli.ErrorStyle + } + + cli.Print(" %s %s %s\n", icon, svc, style.Render(status)) + } + + fmt.Println() +} + +func checkLoadBalancer(ctx context.Context, token string) { + hc := infra.NewHCloudClient(token) + lbs, err := hc.ListLoadBalancers(ctx) + if err != nil { + cli.Print(" %s Load balancer: %s\n", cli.ErrorStyle.Render("✗"), err) + return + } + + if len(lbs) == 0 { + cli.Print(" %s No load balancers found\n", cli.DimStyle.Render("○")) + return + } + + for _, lb := range lbs { + cli.Print(" %s LB: %s IP: %s Targets: %d\n", + cli.SuccessStyle.Render("●"), + cli.BoldStyle.Render(lb.Name), + lb.PublicNet.IPv4.IP, + len(lb.Targets)) + + for _, t := range lb.Targets { + for _, hs := range t.HealthStatus { + icon := cli.SuccessStyle.Render("●") + if hs.Status != "healthy" { + icon = cli.ErrorStyle.Render("○") + } + ip := "" + if t.IP != nil { + ip = t.IP.IP + } + cli.Print(" %s :%d %s %s\n", icon, hs.ListenPort, hs.Status, cli.DimStyle.Render(ip)) + } + } + } +} + +func loadConfig() (*infra.Config, string, error) { + if infraFile != "" { + cfg, err := infra.Load(infraFile) + return cfg, infraFile, err + } + + cwd, err := os.Getwd() + if err != nil { + return nil, "", err + } + + return infra.Discover(cwd) +} diff --git a/go.mod b/go.mod index 7061ef3..d1e696d 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,48 @@ module forge.lthn.ai/core/go-infra go 1.26.0 require ( + forge.lthn.ai/core/cli v0.1.0 + forge.lthn.ai/core/go-ansible v0.1.0 + forge.lthn.ai/core/go-i18n v0.1.0 + forge.lthn.ai/core/go-io v0.0.3 + forge.lthn.ai/core/go-log v0.0.1 + forge.lthn.ai/core/go-scm v0.1.7 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) require ( + forge.lthn.ai/core/go v0.1.0 // indirect + forge.lthn.ai/core/go-crypt v0.1.0 // indirect + forge.lthn.ai/core/go-inference v0.0.2 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index e575324..ed483d9 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,99 @@ +forge.lthn.ai/core/cli v0.1.0 h1:2XRiEMVzUElnQlZnHYDyfKIKQVPcCzGuYHlnz55GjsM= +forge.lthn.ai/core/cli v0.1.0/go.mod h1:mZ7dzccfzo0BP2dE7Mwuw9dXuIowiEd1G5ZGMoLuxVc= +forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI= +forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ= +forge.lthn.ai/core/go-ansible v0.1.0 h1:yl3FaUpN/U2Iap0nuTNIHd2k1nsSb0OatwxMdnNyKD8= +forge.lthn.ai/core/go-ansible v0.1.0/go.mod h1:mJ9DOf0RwtBm6nY8p3dW6q4dR7Obh3lRLI4LMDSDpMc= +forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw= +forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw= +forge.lthn.ai/core/go-i18n v0.1.0 h1:F7JVSoVkZtzx9JfhpntM9z3iQm1vnuMUi/Zklhz8PCI= +forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs= +forge.lthn.ai/core/go-inference v0.0.2 h1:aHjBkYyLKxLr9tbO4AvzzV/lsZueGq/jeo33SLh113k= +forge.lthn.ai/core/go-inference v0.0.2/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= +forge.lthn.ai/core/go-io v0.0.3 h1:TlhYpGTyjPgAlbEHyYrVSeUChZPhJXcLZ7D/8IbFqfI= +forge.lthn.ai/core/go-io v0.0.3/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= +forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= +forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +forge.lthn.ai/core/go-scm v0.1.7 h1:UuIqOLal4MUNS4UoWlBBOz1b/13QlcxnMnx9e5fIHWM= +forge.lthn.ai/core/go-scm v0.1.7/go.mod h1:3GSEnNWyak3ETGehOW2DEcjWscBlNF5JHu31z2hHTVM= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=