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 <virgil@lethean.io>
This commit is contained in:
parent
11e32fe965
commit
5626d99a17
11 changed files with 1722 additions and 1 deletions
47
cmd/monitor/cmd_commands.go
Normal file
47
cmd/monitor/cmd_commands.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
586
cmd/monitor/cmd_monitor.go
Normal file
586
cmd/monitor/cmd_monitor.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
14
cmd/prod/cmd_commands.go
Normal file
14
cmd/prod/cmd_commands.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
129
cmd/prod/cmd_dns.go
Normal file
129
cmd/prod/cmd_dns.go
Normal file
|
|
@ -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 <host> <type> <value>",
|
||||
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
|
||||
}
|
||||
113
cmd/prod/cmd_lb.go
Normal file
113
cmd/prod/cmd_lb.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
35
cmd/prod/cmd_prod.go
Normal file
35
cmd/prod/cmd_prod.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
284
cmd/prod/cmd_setup.go
Normal file
284
cmd/prod/cmd_setup.go
Normal file
|
|
@ -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
|
||||
}
|
||||
63
cmd/prod/cmd_ssh.go
Normal file
63
cmd/prod/cmd_ssh.go
Normal file
|
|
@ -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 <host>",
|
||||
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())
|
||||
}
|
||||
324
cmd/prod/cmd_status.go
Normal file
324
cmd/prod/cmd_status.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
37
go.mod
37
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
|
||||
)
|
||||
|
|
|
|||
91
go.sum
91
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=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue