A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

add new upcloud cli deploy

evan.jarrett.net cd479453 ef0161fb

verified
+2328 -43
+2 -2
.gitignore
··· 14 14 # Environment configuration 15 15 .env 16 16 17 - # Docker-created quota config (actual config is in deploy/quotas.yaml) 18 - quotas.yaml 17 + # Deploy state (contains server UUIDs and IPs) 18 + deploy/upcloud/state.json 19 19 20 20 # Generated assets (run go generate to rebuild) 21 21 pkg/appview/licenses/spdx-licenses.json
+2 -2
config-appview.example.yaml
··· 35 35 client_name: AT Container Registry 36 36 # Short name used in page titles and browser tabs. 37 37 client_short_name: ATCR 38 - # Separate domain for OCI registry API (e.g. "buoy.cr"). Browser visits redirect to BaseURL. 39 - registry_domain: "" 38 + # Separate domains for OCI registry API (e.g. ["buoy.cr"]). First is primary. Browser visits redirect to BaseURL. 39 + registry_domains: [] 40 40 # Web UI settings. 41 41 ui: 42 42 # SQLite database for OAuth sessions, stars, pull counts, and device approvals.
+191
deploy/upcloud/cloudinit.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + _ "embed" 6 + "fmt" 7 + "strings" 8 + "text/template" 9 + ) 10 + 11 + //go:embed systemd/appview.service.tmpl 12 + var appviewServiceTmpl string 13 + 14 + //go:embed systemd/hold.service.tmpl 15 + var holdServiceTmpl string 16 + 17 + //go:embed configs/appview.yaml.tmpl 18 + var appviewConfigTmpl string 19 + 20 + //go:embed configs/hold.yaml.tmpl 21 + var holdConfigTmpl string 22 + 23 + //go:embed configs/cloudinit.sh.tmpl 24 + var cloudInitTmpl string 25 + 26 + // ConfigValues holds values injected into config YAML templates. 27 + // Only truly dynamic/computed values belong here — deployment-specific 28 + // values like client_name, owner_did, etc. are literal in the templates. 29 + type ConfigValues struct { 30 + // S3 / Object Storage 31 + S3Endpoint string 32 + S3Region string 33 + S3Bucket string 34 + S3AccessKey string 35 + S3SecretKey string 36 + 37 + // Infrastructure (computed from zone + config) 38 + Zone string // e.g. "us-chi1" 39 + HoldDomain string // e.g. "us-chi1.cove.seamark.dev" 40 + HoldDid string // e.g. "did:web:us-chi1.cove.seamark.dev" 41 + BasePath string // e.g. "/var/lib/seamark" 42 + } 43 + 44 + // renderConfig executes a Go template with the given values. 45 + func renderConfig(tmplStr string, vals *ConfigValues) (string, error) { 46 + t, err := template.New("config").Parse(tmplStr) 47 + if err != nil { 48 + return "", fmt.Errorf("parse config template: %w", err) 49 + } 50 + var buf bytes.Buffer 51 + if err := t.Execute(&buf, vals); err != nil { 52 + return "", fmt.Errorf("render config template: %w", err) 53 + } 54 + return buf.String(), nil 55 + } 56 + 57 + // serviceUnitParams holds values for rendering systemd service unit templates. 58 + type serviceUnitParams struct { 59 + DisplayName string // e.g. "Seamark" 60 + User string // e.g. "seamark" 61 + BinaryPath string // e.g. "/opt/seamark/bin/seamark-appview" 62 + ConfigPath string // e.g. "/etc/seamark/appview.yaml" 63 + DataDir string // e.g. "/var/lib/seamark" 64 + ServiceName string // e.g. "seamark-appview" 65 + } 66 + 67 + func renderServiceUnit(tmplStr string, p serviceUnitParams) (string, error) { 68 + t, err := template.New("service").Parse(tmplStr) 69 + if err != nil { 70 + return "", fmt.Errorf("parse service template: %w", err) 71 + } 72 + var buf bytes.Buffer 73 + if err := t.Execute(&buf, p); err != nil { 74 + return "", fmt.Errorf("render service template: %w", err) 75 + } 76 + return buf.String(), nil 77 + } 78 + 79 + // generateAppviewCloudInit generates the cloud-init user-data script for the appview server. 80 + func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) { 81 + naming := cfg.Naming() 82 + 83 + configYAML, err := renderConfig(appviewConfigTmpl, vals) 84 + if err != nil { 85 + return "", fmt.Errorf("appview config: %w", err) 86 + } 87 + 88 + serviceUnit, err := renderServiceUnit(appviewServiceTmpl, serviceUnitParams{ 89 + DisplayName: naming.DisplayName(), 90 + User: naming.SystemUser(), 91 + BinaryPath: naming.InstallDir() + "/bin/" + naming.Appview(), 92 + ConfigPath: naming.AppviewConfigPath(), 93 + DataDir: naming.BasePath(), 94 + ServiceName: naming.Appview(), 95 + }) 96 + if err != nil { 97 + return "", fmt.Errorf("appview service unit: %w", err) 98 + } 99 + 100 + return generateCloudInit(cloudInitParams{ 101 + GoVersion: goVersion, 102 + BinaryName: naming.Appview(), 103 + BuildCmd: "appview", 104 + ServiceUnit: serviceUnit, 105 + ConfigYAML: configYAML, 106 + ConfigPath: naming.AppviewConfigPath(), 107 + ServiceName: naming.Appview(), 108 + DataDir: naming.BasePath(), 109 + RepoURL: cfg.RepoURL, 110 + RepoBranch: cfg.RepoBranch, 111 + InstallDir: naming.InstallDir(), 112 + SystemUser: naming.SystemUser(), 113 + ConfigDir: naming.ConfigDir(), 114 + LogFile: naming.LogFile(), 115 + DisplayName: naming.DisplayName(), 116 + }) 117 + } 118 + 119 + // generateHoldCloudInit generates the cloud-init user-data script for the hold server. 120 + func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) { 121 + naming := cfg.Naming() 122 + 123 + configYAML, err := renderConfig(holdConfigTmpl, vals) 124 + if err != nil { 125 + return "", fmt.Errorf("hold config: %w", err) 126 + } 127 + 128 + serviceUnit, err := renderServiceUnit(holdServiceTmpl, serviceUnitParams{ 129 + DisplayName: naming.DisplayName(), 130 + User: naming.SystemUser(), 131 + BinaryPath: naming.InstallDir() + "/bin/" + naming.Hold(), 132 + ConfigPath: naming.HoldConfigPath(), 133 + DataDir: naming.BasePath(), 134 + ServiceName: naming.Hold(), 135 + }) 136 + if err != nil { 137 + return "", fmt.Errorf("hold service unit: %w", err) 138 + } 139 + 140 + return generateCloudInit(cloudInitParams{ 141 + GoVersion: goVersion, 142 + BinaryName: naming.Hold(), 143 + BuildCmd: "hold", 144 + ServiceUnit: serviceUnit, 145 + ConfigYAML: configYAML, 146 + ConfigPath: naming.HoldConfigPath(), 147 + ServiceName: naming.Hold(), 148 + DataDir: naming.BasePath(), 149 + RepoURL: cfg.RepoURL, 150 + RepoBranch: cfg.RepoBranch, 151 + InstallDir: naming.InstallDir(), 152 + SystemUser: naming.SystemUser(), 153 + ConfigDir: naming.ConfigDir(), 154 + LogFile: naming.LogFile(), 155 + DisplayName: naming.DisplayName(), 156 + }) 157 + } 158 + 159 + type cloudInitParams struct { 160 + GoVersion string 161 + BinaryName string 162 + BuildCmd string 163 + ServiceUnit string 164 + ConfigYAML string 165 + ConfigPath string 166 + ServiceName string 167 + DataDir string 168 + RepoURL string 169 + RepoBranch string 170 + InstallDir string 171 + SystemUser string 172 + ConfigDir string 173 + LogFile string 174 + DisplayName string 175 + } 176 + 177 + func generateCloudInit(p cloudInitParams) (string, error) { 178 + // Escape single quotes in embedded content for heredoc safety 179 + p.ServiceUnit = strings.ReplaceAll(p.ServiceUnit, "'", "'\\''") 180 + p.ConfigYAML = strings.ReplaceAll(p.ConfigYAML, "'", "'\\''") 181 + 182 + t, err := template.New("cloudinit").Parse(cloudInitTmpl) 183 + if err != nil { 184 + return "", fmt.Errorf("parse cloudinit template: %w", err) 185 + } 186 + var buf bytes.Buffer 187 + if err := t.Execute(&buf, p); err != nil { 188 + return "", fmt.Errorf("render cloudinit template: %w", err) 189 + } 190 + return buf.String(), nil 191 + }
+143
deploy/upcloud/config.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "strings" 8 + "time" 9 + 10 + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/client" 11 + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/service" 12 + "go.yaml.in/yaml/v3" 13 + ) 14 + 15 + const ( 16 + repoURL = "https://tangled.org/@evan.jarrett.net/at-container-registry" 17 + repoBranch = "main" 18 + privateNetworkCIDR = "10.0.1.0/24" 19 + ) 20 + 21 + // InfraConfig holds infrastructure configuration. 22 + type InfraConfig struct { 23 + Zone string 24 + Plan string 25 + SSHPublicKey string 26 + S3SecretKey string 27 + 28 + // Infrastructure naming — derived from configs/appview.yaml.tmpl. 29 + // Edit that template to rebrand. 30 + ClientName string 31 + BaseDomain string 32 + RegistryDomains []string 33 + RepoURL string 34 + RepoBranch string 35 + } 36 + 37 + // Naming returns a Naming helper derived from ClientName. 38 + func (c *InfraConfig) Naming() Naming { 39 + return Naming{ClientName: c.ClientName} 40 + } 41 + 42 + func loadConfig(zone, plan, sshKeyPath, s3Secret string) (*InfraConfig, error) { 43 + sshKey, err := readSSHPublicKey(sshKeyPath) 44 + if err != nil { 45 + return nil, err 46 + } 47 + 48 + clientName, baseDomain, registryDomains, err := extractFromAppviewTemplate() 49 + if err != nil { 50 + return nil, fmt.Errorf("extract config from template: %w", err) 51 + } 52 + 53 + return &InfraConfig{ 54 + Zone: zone, 55 + Plan: plan, 56 + SSHPublicKey: sshKey, 57 + S3SecretKey: s3Secret, 58 + ClientName: clientName, 59 + BaseDomain: baseDomain, 60 + RegistryDomains: registryDomains, 61 + RepoURL: repoURL, 62 + RepoBranch: repoBranch, 63 + }, nil 64 + } 65 + 66 + // extractFromAppviewTemplate renders the appview config template with 67 + // zero-value ConfigValues and parses the resulting YAML to extract 68 + // deployment-specific values. The template is the single source of truth. 69 + func extractFromAppviewTemplate() (clientName, baseDomain string, registryDomains []string, err error) { 70 + rendered, err := renderConfig(appviewConfigTmpl, &ConfigValues{}) 71 + if err != nil { 72 + return "", "", nil, fmt.Errorf("render appview template: %w", err) 73 + } 74 + 75 + var cfg struct { 76 + Server struct { 77 + BaseURL string `yaml:"base_url"` 78 + ClientName string `yaml:"client_name"` 79 + RegistryDomains []string `yaml:"registry_domains"` 80 + } `yaml:"server"` 81 + } 82 + if err := yaml.Unmarshal([]byte(rendered), &cfg); err != nil { 83 + return "", "", nil, fmt.Errorf("parse appview template YAML: %w", err) 84 + } 85 + 86 + clientName = strings.ToLower(cfg.Server.ClientName) 87 + baseDomain = strings.TrimPrefix(cfg.Server.BaseURL, "https://") 88 + registryDomains = cfg.Server.RegistryDomains 89 + 90 + return clientName, baseDomain, registryDomains, nil 91 + } 92 + 93 + // readSSHPublicKey reads an SSH public key from a file path. 94 + func readSSHPublicKey(path string) (string, error) { 95 + if path == "" { 96 + return "", fmt.Errorf("--ssh-key is required (path to SSH public key file)") 97 + } 98 + data, err := os.ReadFile(path) 99 + if err != nil { 100 + return "", fmt.Errorf("read SSH public key %s: %w", path, err) 101 + } 102 + key := strings.TrimSpace(string(data)) 103 + if key == "" { 104 + return "", fmt.Errorf("SSH public key file %s is empty", path) 105 + } 106 + return key, nil 107 + } 108 + 109 + // resolveInteractive fills in any empty Zone/Plan fields by launching 110 + // interactive TUI pickers that query the UpCloud API. 111 + func resolveInteractive(ctx context.Context, svc *service.Service, cfg *InfraConfig) error { 112 + if cfg.Zone == "" { 113 + z, err := pickZone(ctx, svc) 114 + if err != nil { 115 + return fmt.Errorf("zone picker: %w", err) 116 + } 117 + cfg.Zone = z 118 + } 119 + if cfg.Plan == "" { 120 + p, err := pickPlan(ctx, svc) 121 + if err != nil { 122 + return fmt.Errorf("plan picker: %w", err) 123 + } 124 + cfg.Plan = p 125 + } 126 + return nil 127 + } 128 + 129 + // newService creates an UpCloud API client. If token is non-empty it's used 130 + // directly; otherwise credentials are read from UPCLOUD_TOKEN env var. 131 + func newService(token string) (*service.Service, error) { 132 + var c *client.Client 133 + var err error 134 + if token != "" { 135 + c = client.New("", "", client.WithBearerAuth(token), client.WithTimeout(120*time.Second)) 136 + } else { 137 + c, err = client.NewFromEnv(client.WithTimeout(120 * time.Second)) 138 + if err != nil { 139 + return nil, fmt.Errorf("create UpCloud client: %w\n\nPass --token or set UPCLOUD_TOKEN", err) 140 + } 141 + } 142 + return service.New(c), nil 143 + }
+25
deploy/upcloud/configs/appview.yaml.tmpl
··· 1 + version: "0.1" 2 + log_level: info 3 + server: 4 + addr: :5000 5 + base_url: "https://seamark.dev" 6 + default_hold_did: "{{.HoldDid}}" 7 + oauth_key_path: "{{.BasePath}}/oauth/client.key" 8 + client_name: Seamark 9 + client_short_name: Seamark 10 + registry_domains: 11 + - "buoy.cr" 12 + - "bouy.cr" 13 + ui: 14 + database_path: "{{.BasePath}}/ui.db" 15 + theme: seamark 16 + jetstream: 17 + url: wss://jetstream2.us-west.bsky.network/subscribe 18 + backfill_enabled: true 19 + relay_endpoint: https://relay1.us-east.bsky.network 20 + auth: 21 + key_path: "{{.BasePath}}/auth/private-key.pem" 22 + cert_path: "{{.BasePath}}/auth/private-key.crt" 23 + legal: 24 + company_name: Seamark 25 + jurisdiction: State of Texas, United States
+72
deploy/upcloud/configs/cloudinit.sh.tmpl
··· 1 + #!/bin/bash 2 + set -euo pipefail 3 + exec > >(tee {{.LogFile}}) 2>&1 4 + 5 + echo "=== {{.DisplayName}} Setup: {{.BinaryName}} ===" 6 + echo "Started at $(date -u)" 7 + 8 + # Wait for DNS resolution 9 + echo "Waiting for DNS..." 10 + for i in $(seq 1 30); do 11 + if host go.dev >/dev/null 2>&1; then 12 + echo "DNS ready after ${i}s" 13 + break 14 + fi 15 + sleep 1 16 + done 17 + 18 + # System packages 19 + export DEBIAN_FRONTEND=noninteractive 20 + apt-get update && apt-get upgrade -y 21 + apt-get install -y git gcc make curl libsqlite3-dev nodejs npm htop 22 + 23 + # Swap (for builds on small instances) 24 + if [ ! -f /swapfile ]; then 25 + dd if=/dev/zero of=/swapfile bs=1M count=2048 26 + chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile 27 + echo '/swapfile none swap sw 0 0' >> /etc/fstab 28 + fi 29 + 30 + # Go {{.GoVersion}} 31 + curl -fsSL https://go.dev/dl/go{{.GoVersion}}.linux-amd64.tar.gz | tar -C /usr/local -xz 32 + echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh 33 + export PATH=$PATH:/usr/local/go/bin 34 + export GOTMPDIR=/var/tmp 35 + 36 + # Clone & build 37 + if [ -d {{.InstallDir}} ]; then 38 + cd {{.InstallDir}} && git pull origin {{.RepoBranch}} 39 + else 40 + git clone -b {{.RepoBranch}} {{.RepoURL}} {{.InstallDir}} 41 + cd {{.InstallDir}} 42 + fi 43 + npm ci 44 + go generate ./... 45 + CGO_ENABLED=1 go build \ 46 + -ldflags="-s -w -linkmode external -extldflags '-static'" \ 47 + -tags sqlite_omit_load_extension -trimpath \ 48 + -o bin/{{.BinaryName}} ./cmd/{{.BuildCmd}} 49 + 50 + # Service user & data dirs 51 + useradd --system --no-create-home --shell /usr/sbin/nologin {{.SystemUser}} || true 52 + mkdir -p {{.DataDir}} && chown {{.SystemUser}}:{{.SystemUser}} {{.DataDir}} 53 + 54 + # Config file 55 + mkdir -p {{.ConfigDir}} 56 + if [ ! -f {{.ConfigPath}} ]; then 57 + cat > {{.ConfigPath}} << 'CFGEOF' 58 + {{.ConfigYAML}} 59 + CFGEOF 60 + else 61 + echo "Config {{.ConfigPath}} already exists, skipping" 62 + fi 63 + 64 + # Systemd service 65 + cat > /etc/systemd/system/{{.ServiceName}}.service << 'SVCEOF' 66 + {{.ServiceUnit}} 67 + SVCEOF 68 + systemctl daemon-reload 69 + systemctl enable {{.ServiceName}} 70 + 71 + echo "=== Setup complete at $(date -u) ===" 72 + echo "Edit {{.ConfigPath}} then: systemctl start {{.ServiceName}}"
+30
deploy/upcloud/configs/hold.yaml.tmpl
··· 1 + version: "0.1" 2 + log_level: info 3 + storage: 4 + access_key: "{{.S3AccessKey}}" 5 + secret_key: "{{.S3SecretKey}}" 6 + region: "{{.S3Region}}" 7 + bucket: "{{.S3Bucket}}" 8 + endpoint: "{{.S3Endpoint}}" 9 + server: 10 + addr: :8080 11 + public_url: "https://{{.HoldDomain}}" 12 + public: false 13 + registration: 14 + owner_did: "did:plc:pddp4xt5lgnv2qsegbzzs4xg" 15 + allow_all_crew: true 16 + enable_bluesky_posts: false 17 + database: 18 + path: "{{.BasePath}}" 19 + admin: 20 + enabled: true 21 + quota: 22 + tiers: 23 + deckhand: 24 + quota: 5GB 25 + bosun: 26 + quota: 50GB 27 + quartermaster: 28 + quota: 100GB 29 + defaults: 30 + new_crew_tier: deckhand
+45
deploy/upcloud/go.mod
··· 1 + module atcr.io/deploy 2 + 3 + go 1.25.4 4 + 5 + require ( 6 + github.com/UpCloudLtd/upcloud-go-api/v8 v8.34.3 7 + github.com/charmbracelet/huh v0.8.0 8 + github.com/spf13/cobra v1.10.2 9 + go.yaml.in/yaml/v3 v3.0.4 10 + ) 11 + 12 + require ( 13 + github.com/atotto/clipboard v0.1.4 // indirect 14 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 15 + github.com/catppuccin/go v0.3.0 // indirect 16 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect 17 + github.com/charmbracelet/bubbletea v1.3.6 // indirect 18 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 19 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 20 + github.com/charmbracelet/x/ansi v0.9.3 // indirect 21 + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 22 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect 23 + github.com/charmbracelet/x/term v0.2.1 // indirect 24 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 25 + github.com/dustin/go-humanize v1.0.1 // indirect 26 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 27 + github.com/inconshreveable/mousetrap v1.1.0 // indirect 28 + github.com/kr/text v0.2.0 // indirect 29 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 30 + github.com/mattn/go-isatty v0.0.20 // indirect 31 + github.com/mattn/go-localereader v0.0.1 // indirect 32 + github.com/mattn/go-runewidth v0.0.16 // indirect 33 + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 34 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 35 + github.com/muesli/cancelreader v0.2.2 // indirect 36 + github.com/muesli/termenv v0.16.0 // indirect 37 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 38 + github.com/rivo/uniseg v0.4.7 // indirect 39 + github.com/rogpeppe/go-internal v1.14.1 // indirect 40 + github.com/spf13/pflag v1.0.9 // indirect 41 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 42 + golang.org/x/sync v0.15.0 // indirect 43 + golang.org/x/sys v0.33.0 // indirect 44 + golang.org/x/text v0.23.0 // indirect 45 + )
+109
deploy/upcloud/go.sum
··· 1 + github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 2 + github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 3 + github.com/UpCloudLtd/upcloud-go-api/v8 v8.34.3 h1:7ba03u4L5LafZPVO2k6B0/f114k5dFF3GtAN7FEKfno= 4 + github.com/UpCloudLtd/upcloud-go-api/v8 v8.34.3/go.mod h1:NBh1d/ip1bhdAIhuPWbyPme7tbLzDTV7dhutUmU1vg8= 5 + github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 6 + github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 7 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 + github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 10 + github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 11 + github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 12 + github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 13 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= 14 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 15 + github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= 16 + github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 17 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 18 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 19 + github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= 20 + github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= 21 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 22 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 23 + github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 24 + github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 25 + github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 26 + github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 27 + github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 28 + github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 29 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 30 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 31 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 32 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 33 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= 34 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 35 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 36 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 37 + github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 38 + github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 39 + github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= 40 + github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 41 + github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 42 + github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 43 + github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 44 + github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 45 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 46 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 47 + github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= 48 + github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= 49 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 50 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 51 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 52 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 53 + github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 54 + github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 55 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 56 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 57 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 58 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 59 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 60 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 61 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 62 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 63 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 64 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 65 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 66 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 67 + github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 68 + github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 69 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 70 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 71 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 72 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 73 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 74 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 75 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 76 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 77 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 78 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 79 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 80 + github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 81 + github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 82 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 83 + github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 84 + github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 85 + github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 86 + github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 87 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 88 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 89 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 90 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 91 + go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 92 + go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 93 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 94 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 95 + golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 96 + golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 97 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 100 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 101 + golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 102 + golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 103 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 104 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 105 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 106 + gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 107 + gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 108 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 109 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+35
deploy/upcloud/goversion.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "runtime" 8 + "strings" 9 + ) 10 + 11 + // requiredGoVersion reads the Go version from the root go.mod file. 12 + // Returns a version string like "1.25.4" for use in download URLs. 13 + func requiredGoVersion() (string, error) { 14 + _, thisFile, _, _ := runtime.Caller(0) 15 + rootMod := filepath.Join(filepath.Dir(thisFile), "..", "..", "go.mod") 16 + 17 + data, err := os.ReadFile(rootMod) 18 + if err != nil { 19 + return "", fmt.Errorf("read root go.mod: %w", err) 20 + } 21 + 22 + for _, line := range strings.Split(string(data), "\n") { 23 + line = strings.TrimSpace(line) 24 + if strings.HasPrefix(line, "go ") { 25 + version := strings.TrimPrefix(line, "go ") 26 + version = strings.TrimSpace(version) 27 + // Validate it looks like a version 28 + if len(version) > 0 && version[0] >= '1' && version[0] <= '9' { 29 + return version, nil 30 + } 31 + } 32 + } 33 + 34 + return "", fmt.Errorf("no 'go X.Y.Z' directive found in %s", rootMod) 35 + }
+23
deploy/upcloud/main.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + 6 + "github.com/spf13/cobra" 7 + ) 8 + 9 + var rootCmd = &cobra.Command{ 10 + Use: "upcloud", 11 + Short: "ATCR infrastructure provisioning tool for UpCloud", 12 + SilenceUsage: true, 13 + } 14 + 15 + func init() { 16 + rootCmd.PersistentFlags().StringP("token", "t", "", "UpCloud API token (env: UPCLOUD_TOKEN)") 17 + } 18 + 19 + func main() { 20 + if err := rootCmd.Execute(); err != nil { 21 + os.Exit(1) 22 + } 23 + }
+52
deploy/upcloud/naming.go
··· 1 + package main 2 + 3 + import "strings" 4 + 5 + // Naming derives all infrastructure names and paths from a single ClientName. 6 + type Naming struct { 7 + ClientName string // e.g. "seamark" 8 + } 9 + 10 + // DisplayName returns the title-cased client name (e.g. "Seamark"). 11 + func (n Naming) DisplayName() string { 12 + if n.ClientName == "" { 13 + return "" 14 + } 15 + return strings.ToUpper(n.ClientName[:1]) + n.ClientName[1:] 16 + } 17 + 18 + // SystemUser returns the unix user name. 19 + func (n Naming) SystemUser() string { return n.ClientName } 20 + 21 + // InstallDir returns the source/build directory (e.g. "/opt/seamark"). 22 + func (n Naming) InstallDir() string { return "/opt/" + n.ClientName } 23 + 24 + // ConfigDir returns the config directory (e.g. "/etc/seamark"). 25 + func (n Naming) ConfigDir() string { return "/etc/" + n.ClientName } 26 + 27 + // BasePath returns the data directory (e.g. "/var/lib/seamark"). 28 + func (n Naming) BasePath() string { return "/var/lib/" + n.ClientName } 29 + 30 + // LogFile returns the setup log path (e.g. "/var/log/seamark-setup.log"). 31 + func (n Naming) LogFile() string { return "/var/log/" + n.ClientName + "-setup.log" } 32 + 33 + // Appview returns the appview binary/service/server name (e.g. "seamark-appview"). 34 + func (n Naming) Appview() string { return n.ClientName + "-appview" } 35 + 36 + // Hold returns the hold binary/service/server name (e.g. "seamark-hold"). 37 + func (n Naming) Hold() string { return n.ClientName + "-hold" } 38 + 39 + // AppviewConfigPath returns the appview config file path. 40 + func (n Naming) AppviewConfigPath() string { return n.ConfigDir() + "/appview.yaml" } 41 + 42 + // HoldConfigPath returns the hold config file path. 43 + func (n Naming) HoldConfigPath() string { return n.ConfigDir() + "/hold.yaml" } 44 + 45 + // NetworkName returns the private network name (e.g. "seamark-private"). 46 + func (n Naming) NetworkName() string { return n.ClientName + "-private" } 47 + 48 + // LBName returns the load balancer name (e.g. "seamark-lb"). 49 + func (n Naming) LBName() string { return n.ClientName + "-lb" } 50 + 51 + // S3Name returns the name used for S3 storage, user, and bucket. 52 + func (n Naming) S3Name() string { return n.ClientName }
+88
deploy/upcloud/picker.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "sort" 7 + 8 + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" 9 + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/service" 10 + "github.com/charmbracelet/huh" 11 + ) 12 + 13 + // pickZone fetches available zones from the UpCloud API and presents an 14 + // interactive selector. Only public zones are shown. 15 + func pickZone(ctx context.Context, svc *service.Service) (string, error) { 16 + resp, err := svc.GetZones(ctx) 17 + if err != nil { 18 + return "", fmt.Errorf("fetch zones: %w", err) 19 + } 20 + 21 + var opts []huh.Option[string] 22 + for _, z := range resp.Zones { 23 + if z.Public != upcloud.True { 24 + continue 25 + } 26 + label := fmt.Sprintf("%s — %s", z.ID, z.Description) 27 + opts = append(opts, huh.NewOption(label, z.ID)) 28 + } 29 + 30 + if len(opts) == 0 { 31 + return "", fmt.Errorf("no public zones available") 32 + } 33 + 34 + sort.Slice(opts, func(i, j int) bool { 35 + return opts[i].Value < opts[j].Value 36 + }) 37 + 38 + var zone string 39 + err = huh.NewSelect[string](). 40 + Title("Select a zone"). 41 + Options(opts...). 42 + Value(&zone). 43 + Run() 44 + if err != nil { 45 + return "", err 46 + } 47 + 48 + return zone, nil 49 + } 50 + 51 + // pickPlan fetches available plans from the UpCloud API and presents an 52 + // interactive selector. GPU plans are filtered out. 53 + func pickPlan(ctx context.Context, svc *service.Service) (string, error) { 54 + resp, err := svc.GetPlans(ctx) 55 + if err != nil { 56 + return "", fmt.Errorf("fetch plans: %w", err) 57 + } 58 + 59 + var opts []huh.Option[string] 60 + for _, p := range resp.Plans { 61 + if p.GPUAmount > 0 { 62 + continue 63 + } 64 + memGB := p.MemoryAmount / 1024 65 + label := fmt.Sprintf("%s — %d CPU, %d GB RAM, %d GB disk", p.Name, p.CoreNumber, memGB, p.StorageSize) 66 + opts = append(opts, huh.NewOption(label, p.Name)) 67 + } 68 + 69 + if len(opts) == 0 { 70 + return "", fmt.Errorf("no plans available") 71 + } 72 + 73 + sort.Slice(opts, func(i, j int) bool { 74 + return opts[i].Value < opts[j].Value 75 + }) 76 + 77 + var plan string 78 + err = huh.NewSelect[string](). 79 + Title("Select a plan"). 80 + Options(opts...). 81 + Value(&plan). 82 + Run() 83 + if err != nil { 84 + return "", err 85 + } 86 + 87 + return plan, nil 88 + }
+856
deploy/upcloud/provision.go
··· 1 + package main 2 + 3 + import ( 4 + "bufio" 5 + "context" 6 + "crypto/sha256" 7 + "fmt" 8 + "os" 9 + "strings" 10 + "time" 11 + 12 + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" 13 + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" 14 + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/service" 15 + "github.com/spf13/cobra" 16 + ) 17 + 18 + var provisionCmd = &cobra.Command{ 19 + Use: "provision", 20 + Short: "Create all infrastructure (servers, network, LB, firewall)", 21 + RunE: func(cmd *cobra.Command, args []string) error { 22 + token, _ := cmd.Root().PersistentFlags().GetString("token") 23 + zone, _ := cmd.Flags().GetString("zone") 24 + plan, _ := cmd.Flags().GetString("plan") 25 + sshKey, _ := cmd.Flags().GetString("ssh-key") 26 + s3Secret, _ := cmd.Flags().GetString("s3-secret") 27 + return cmdProvision(token, zone, plan, sshKey, s3Secret) 28 + }, 29 + } 30 + 31 + func init() { 32 + provisionCmd.Flags().String("zone", "", "UpCloud zone (interactive picker if omitted)") 33 + provisionCmd.Flags().String("plan", "", "Server plan (interactive picker if omitted)") 34 + provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)") 35 + provisionCmd.Flags().String("s3-secret", "", "S3 secret access key (for existing object storage)") 36 + provisionCmd.MarkFlagRequired("ssh-key") 37 + rootCmd.AddCommand(provisionCmd) 38 + } 39 + 40 + func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { 41 + cfg, err := loadConfig(zone, plan, sshKeyPath, s3Secret) 42 + if err != nil { 43 + return err 44 + } 45 + 46 + naming := cfg.Naming() 47 + 48 + svc, err := newService(token) 49 + if err != nil { 50 + return err 51 + } 52 + 53 + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) 54 + defer cancel() 55 + 56 + // Load existing state or start fresh 57 + state, err := loadState() 58 + if err != nil { 59 + state = &InfraState{} 60 + } 61 + 62 + // Use zone from state if not provided via flags 63 + if cfg.Zone == "" && state.Zone != "" { 64 + cfg.Zone = state.Zone 65 + } 66 + 67 + // Only need interactive picker if we still need to create resources 68 + needsServers := state.Appview.UUID == "" || state.Hold.UUID == "" 69 + if cfg.Zone == "" || (needsServers && cfg.Plan == "") { 70 + if err := resolveInteractive(ctx, svc, cfg); err != nil { 71 + return err 72 + } 73 + } 74 + 75 + if state.Zone == "" { 76 + state.Zone = cfg.Zone 77 + } 78 + state.ClientName = cfg.ClientName 79 + state.RepoBranch = cfg.RepoBranch 80 + 81 + goVersion, err := requiredGoVersion() 82 + if err != nil { 83 + return err 84 + } 85 + 86 + fmt.Printf("Provisioning %s infrastructure in zone %s...\n", naming.DisplayName(), cfg.Zone) 87 + fmt.Printf("Go version: %s (from go.mod)\n", goVersion) 88 + if needsServers { 89 + fmt.Printf("Server plan: %s\n", cfg.Plan) 90 + } 91 + fmt.Println() 92 + 93 + // S3 secret key — from flag for existing storage, from API for new 94 + s3SecretKey := cfg.S3SecretKey 95 + 96 + // 1. Object storage 97 + if state.ObjectStorage.UUID != "" { 98 + fmt.Printf("Object storage: %s (exists)\n", state.ObjectStorage.UUID) 99 + // Refresh discoverable fields if missing (e.g. pre-seeded UUID only) 100 + if state.ObjectStorage.Endpoint == "" || state.ObjectStorage.Bucket == "" { 101 + fmt.Println(" Discovering endpoint, bucket, access key...") 102 + discovered, err := lookupObjectStorage(ctx, svc, state.ObjectStorage.UUID) 103 + if err != nil { 104 + return err 105 + } 106 + state.ObjectStorage.Endpoint = discovered.Endpoint 107 + state.ObjectStorage.Region = discovered.Region 108 + if discovered.Bucket != "" { 109 + state.ObjectStorage.Bucket = discovered.Bucket 110 + } 111 + if discovered.AccessKeyID != "" { 112 + state.ObjectStorage.AccessKeyID = discovered.AccessKeyID 113 + } 114 + saveState(state) 115 + } 116 + } else { 117 + fmt.Println("Creating object storage...") 118 + objState, secretKey, err := provisionObjectStorage(ctx, svc, cfg.Zone, naming.S3Name()) 119 + if err != nil { 120 + return fmt.Errorf("object storage: %w", err) 121 + } 122 + state.ObjectStorage = objState 123 + s3SecretKey = secretKey 124 + saveState(state) 125 + fmt.Printf(" S3 Secret Key: %s\n", secretKey) 126 + } 127 + 128 + fmt.Printf(" Endpoint: %s\n", state.ObjectStorage.Endpoint) 129 + fmt.Printf(" Region: %s\n", state.ObjectStorage.Region) 130 + fmt.Printf(" Bucket: %s\n", state.ObjectStorage.Bucket) 131 + fmt.Printf(" Access Key: %s\n\n", state.ObjectStorage.AccessKeyID) 132 + 133 + // Hold domain is zone-based (e.g. us-chi1.cove.seamark.dev) 134 + holdDomain := cfg.Zone + ".cove." + cfg.BaseDomain 135 + 136 + // Build config template values 137 + vals := &ConfigValues{ 138 + S3Endpoint: state.ObjectStorage.Endpoint, 139 + S3Region: state.ObjectStorage.Region, 140 + S3Bucket: state.ObjectStorage.Bucket, 141 + S3AccessKey: state.ObjectStorage.AccessKeyID, 142 + S3SecretKey: s3SecretKey, 143 + Zone: cfg.Zone, 144 + HoldDomain: holdDomain, 145 + HoldDid: "did:web:" + holdDomain, 146 + BasePath: naming.BasePath(), 147 + } 148 + 149 + // 2. Private network 150 + if state.Network.UUID != "" { 151 + fmt.Printf("Network: %s (exists)\n", state.Network.UUID) 152 + } else { 153 + fmt.Println("Creating private network...") 154 + network, err := svc.CreateNetwork(ctx, &request.CreateNetworkRequest{ 155 + Name: naming.NetworkName(), 156 + Zone: cfg.Zone, 157 + IPNetworks: upcloud.IPNetworkSlice{ 158 + { 159 + Address: privateNetworkCIDR, 160 + DHCP: upcloud.True, 161 + DHCPDefaultRoute: upcloud.False, 162 + DHCPDns: []string{"8.8.8.8", "1.1.1.1"}, 163 + Family: upcloud.IPAddressFamilyIPv4, 164 + Gateway: "", 165 + }, 166 + }, 167 + }) 168 + if err != nil { 169 + return fmt.Errorf("create network: %w", err) 170 + } 171 + state.Network = StateRef{UUID: network.UUID} 172 + saveState(state) 173 + fmt.Printf(" Network: %s (%s)\n", network.UUID, privateNetworkCIDR) 174 + } 175 + 176 + // Find Debian template (needed for server creation) 177 + templateUUID, err := findDebianTemplate(ctx, svc) 178 + if err != nil { 179 + return err 180 + } 181 + 182 + // 3. Appview server 183 + if state.Appview.UUID != "" { 184 + fmt.Printf("Appview: %s (exists)\n", state.Appview.UUID) 185 + appviewScript, err := generateAppviewCloudInit(cfg, vals, goVersion) 186 + if err != nil { 187 + return err 188 + } 189 + if err := syncCloudInit("appview", state.Appview.PublicIP, appviewScript); err != nil { 190 + return err 191 + } 192 + } else { 193 + fmt.Println("Creating appview server...") 194 + appviewUserData, err := generateAppviewCloudInit(cfg, vals, goVersion) 195 + if err != nil { 196 + return err 197 + } 198 + appview, err := createServer(ctx, svc, cfg, templateUUID, state.Network.UUID, naming.Appview(), appviewUserData) 199 + if err != nil { 200 + return fmt.Errorf("create appview: %w", err) 201 + } 202 + state.Appview = *appview 203 + saveState(state) 204 + fmt.Printf(" Appview: %s (public: %s, private: %s)\n", appview.UUID, appview.PublicIP, appview.PrivateIP) 205 + } 206 + 207 + // 4. Hold server 208 + if state.Hold.UUID != "" { 209 + fmt.Printf("Hold: %s (exists)\n", state.Hold.UUID) 210 + holdScript, err := generateHoldCloudInit(cfg, vals, goVersion) 211 + if err != nil { 212 + return err 213 + } 214 + if err := syncCloudInit("hold", state.Hold.PublicIP, holdScript); err != nil { 215 + return err 216 + } 217 + } else { 218 + fmt.Println("Creating hold server...") 219 + holdUserData, err := generateHoldCloudInit(cfg, vals, goVersion) 220 + if err != nil { 221 + return err 222 + } 223 + hold, err := createServer(ctx, svc, cfg, templateUUID, state.Network.UUID, naming.Hold(), holdUserData) 224 + if err != nil { 225 + return fmt.Errorf("create hold: %w", err) 226 + } 227 + state.Hold = *hold 228 + saveState(state) 229 + fmt.Printf(" Hold: %s (public: %s, private: %s)\n", hold.UUID, hold.PublicIP, hold.PrivateIP) 230 + } 231 + 232 + // 5. Firewall rules (idempotent — replaces all rules) 233 + fmt.Println("Configuring firewall rules...") 234 + for _, s := range []struct { 235 + name string 236 + uuid string 237 + }{ 238 + {"appview", state.Appview.UUID}, 239 + {"hold", state.Hold.UUID}, 240 + } { 241 + if err := createFirewallRules(ctx, svc, s.uuid, privateNetworkCIDR); err != nil { 242 + return fmt.Errorf("firewall %s: %w", s.name, err) 243 + } 244 + } 245 + 246 + // 6. Load balancer 247 + if state.LB.UUID != "" { 248 + fmt.Printf("Load balancer: %s (exists)\n", state.LB.UUID) 249 + } else { 250 + fmt.Println("Creating load balancer (Essentials tier)...") 251 + lb, err := createLoadBalancer(ctx, svc, cfg, naming, state.Network.UUID, state.Appview.PrivateIP, state.Hold.PrivateIP, holdDomain) 252 + if err != nil { 253 + return fmt.Errorf("create LB: %w", err) 254 + } 255 + state.LB = StateRef{UUID: lb.UUID} 256 + saveState(state) 257 + } 258 + 259 + // Always reconcile TLS certs (handles partial failures and re-runs) 260 + tlsDomains := []string{cfg.BaseDomain} 261 + tlsDomains = append(tlsDomains, cfg.RegistryDomains...) 262 + tlsDomains = append(tlsDomains, holdDomain) 263 + if err := ensureLBCertificates(ctx, svc, state.LB.UUID, tlsDomains); err != nil { 264 + return fmt.Errorf("LB certificates: %w", err) 265 + } 266 + 267 + // Fetch LB DNS name for output 268 + lbDNS := "" 269 + if state.LB.UUID != "" { 270 + lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{UUID: state.LB.UUID}) 271 + if err == nil { 272 + for _, n := range lb.Networks { 273 + if n.Type == upcloud.LoadBalancerNetworkTypePublic { 274 + lbDNS = n.DNSName 275 + } 276 + } 277 + } 278 + } 279 + 280 + fmt.Println("\n=== Provisioning Complete ===") 281 + fmt.Println() 282 + fmt.Println("DNS records needed:") 283 + if lbDNS != "" { 284 + fmt.Printf(" CNAME %-24s → %s\n", cfg.BaseDomain, lbDNS) 285 + for _, rd := range cfg.RegistryDomains { 286 + fmt.Printf(" CNAME %-24s → %s\n", rd, lbDNS) 287 + } 288 + fmt.Printf(" CNAME %-24s → %s\n", holdDomain, lbDNS) 289 + } else { 290 + fmt.Println(" (LB DNS name not yet available — check 'status' in a few minutes)") 291 + } 292 + fmt.Println() 293 + fmt.Println("SSH access:") 294 + fmt.Printf(" ssh root@%s # appview\n", state.Appview.PublicIP) 295 + fmt.Printf(" ssh root@%s # hold\n", state.Hold.PublicIP) 296 + fmt.Println() 297 + fmt.Println("Next steps:") 298 + fmt.Println(" 1. Wait ~5 min for cloud-init to complete") 299 + fmt.Printf(" 2. systemctl start %s / %s\n", naming.Appview(), naming.Hold()) 300 + fmt.Println(" 3. Configure DNS records above") 301 + 302 + return nil 303 + } 304 + 305 + // provisionObjectStorage creates a new Managed Object Storage with a user, access key, and bucket. 306 + // Returns the state and the secret key separately (only available at creation time). 307 + func provisionObjectStorage(ctx context.Context, svc *service.Service, zone, s3Name string) (ObjectStorageState, string, error) { 308 + // Map compute zone to object storage region (e.g. us-chi1 → us-east-1) 309 + region := objectStorageRegion(zone) 310 + 311 + storage, err := svc.CreateManagedObjectStorage(ctx, &request.CreateManagedObjectStorageRequest{ 312 + Name: s3Name, 313 + Region: region, 314 + ConfiguredStatus: upcloud.ManagedObjectStorageConfiguredStatusStarted, 315 + Networks: []upcloud.ManagedObjectStorageNetwork{ 316 + { 317 + Family: upcloud.IPAddressFamilyIPv4, 318 + Name: "public", 319 + Type: "public", 320 + }, 321 + }, 322 + }) 323 + if err != nil { 324 + return ObjectStorageState{}, "", fmt.Errorf("create storage: %w", err) 325 + } 326 + fmt.Printf(" Created: %s (region: %s)\n", storage.UUID, region) 327 + 328 + // Find endpoint 329 + var endpoint string 330 + for _, ep := range storage.Endpoints { 331 + if ep.DomainName != "" { 332 + endpoint = "https://" + ep.DomainName 333 + break 334 + } 335 + } 336 + 337 + // Create user 338 + _, err = svc.CreateManagedObjectStorageUser(ctx, &request.CreateManagedObjectStorageUserRequest{ 339 + ServiceUUID: storage.UUID, 340 + Username: s3Name, 341 + }) 342 + if err != nil { 343 + return ObjectStorageState{}, "", fmt.Errorf("create user: %w", err) 344 + } 345 + 346 + // Attach admin policy 347 + err = svc.AttachManagedObjectStorageUserPolicy(ctx, &request.AttachManagedObjectStorageUserPolicyRequest{ 348 + ServiceUUID: storage.UUID, 349 + Username: s3Name, 350 + Name: "admin", 351 + }) 352 + if err != nil { 353 + return ObjectStorageState{}, "", fmt.Errorf("attach policy: %w", err) 354 + } 355 + 356 + // Create access key (secret is only returned here) 357 + accessKey, err := svc.CreateManagedObjectStorageUserAccessKey(ctx, &request.CreateManagedObjectStorageUserAccessKeyRequest{ 358 + ServiceUUID: storage.UUID, 359 + Username: s3Name, 360 + }) 361 + if err != nil { 362 + return ObjectStorageState{}, "", fmt.Errorf("create access key: %w", err) 363 + } 364 + 365 + secretKey := "" 366 + if accessKey.SecretAccessKey != nil { 367 + secretKey = *accessKey.SecretAccessKey 368 + } 369 + 370 + // Create bucket 371 + _, err = svc.CreateManagedObjectStorageBucket(ctx, &request.CreateManagedObjectStorageBucketRequest{ 372 + ServiceUUID: storage.UUID, 373 + Name: s3Name, 374 + }) 375 + if err != nil { 376 + return ObjectStorageState{}, "", fmt.Errorf("create bucket: %w", err) 377 + } 378 + 379 + return ObjectStorageState{ 380 + UUID: storage.UUID, 381 + Endpoint: endpoint, 382 + Region: region, 383 + Bucket: s3Name, 384 + AccessKeyID: accessKey.AccessKeyID, 385 + }, secretKey, nil 386 + } 387 + 388 + // objectStorageRegion maps a compute zone to the nearest object storage region. 389 + func objectStorageRegion(zone string) string { 390 + switch { 391 + case strings.HasPrefix(zone, "us-"): 392 + return "us-east-1" 393 + case strings.HasPrefix(zone, "de-"): 394 + return "europe-1" 395 + case strings.HasPrefix(zone, "fi-"): 396 + return "europe-1" 397 + case strings.HasPrefix(zone, "nl-"): 398 + return "europe-1" 399 + case strings.HasPrefix(zone, "es-"): 400 + return "europe-1" 401 + case strings.HasPrefix(zone, "pl-"): 402 + return "europe-1" 403 + case strings.HasPrefix(zone, "se-"): 404 + return "europe-1" 405 + case strings.HasPrefix(zone, "au-"): 406 + return "australia-1" 407 + case strings.HasPrefix(zone, "sg-"): 408 + return "singapore-1" 409 + default: 410 + return "us-east-1" 411 + } 412 + } 413 + 414 + func createServer(ctx context.Context, svc *service.Service, cfg *InfraConfig, templateUUID, networkUUID, title, userData string) (*ServerState, error) { 415 + storageTier := "maxiops" 416 + if strings.HasPrefix(strings.ToUpper(cfg.Plan), "DEV-") { 417 + storageTier = "standard" 418 + } 419 + 420 + // Look up the plan's storage size from the API instead of hardcoding. 421 + diskSize := 25 // fallback 422 + plans, err := svc.GetPlans(ctx) 423 + if err == nil { 424 + for _, p := range plans.Plans { 425 + if p.Name == cfg.Plan { 426 + diskSize = p.StorageSize 427 + break 428 + } 429 + } 430 + } 431 + 432 + details, err := svc.CreateServer(ctx, &request.CreateServerRequest{ 433 + Zone: cfg.Zone, 434 + Title: title, 435 + Hostname: title, 436 + Plan: cfg.Plan, 437 + Metadata: upcloud.True, 438 + UserData: userData, 439 + Firewall: "on", 440 + PasswordDelivery: "none", 441 + StorageDevices: request.CreateServerStorageDeviceSlice{ 442 + { 443 + Action: "clone", 444 + Storage: templateUUID, 445 + Title: title + "-disk", 446 + Size: diskSize, 447 + Tier: storageTier, 448 + }, 449 + }, 450 + Networking: &request.CreateServerNetworking{ 451 + Interfaces: request.CreateServerInterfaceSlice{ 452 + { 453 + Index: 1, 454 + Type: upcloud.IPAddressAccessPublic, 455 + IPAddresses: request.CreateServerIPAddressSlice{ 456 + {Family: upcloud.IPAddressFamilyIPv4}, 457 + }, 458 + }, 459 + { 460 + Index: 2, 461 + Type: upcloud.IPAddressAccessPrivate, 462 + Network: networkUUID, 463 + IPAddresses: request.CreateServerIPAddressSlice{ 464 + {Family: upcloud.IPAddressFamilyIPv4}, 465 + }, 466 + }, 467 + }, 468 + }, 469 + LoginUser: &request.LoginUser{ 470 + CreatePassword: "no", 471 + SSHKeys: request.SSHKeySlice{cfg.SSHPublicKey}, 472 + }, 473 + }) 474 + if err != nil { 475 + return nil, err 476 + } 477 + 478 + fmt.Printf(" Waiting for server %s to start...\n", details.UUID) 479 + details, err = svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{ 480 + UUID: details.UUID, 481 + DesiredState: upcloud.ServerStateStarted, 482 + }) 483 + if err != nil { 484 + return nil, fmt.Errorf("wait for server: %w", err) 485 + } 486 + 487 + s := &ServerState{UUID: details.UUID} 488 + for _, iface := range details.Networking.Interfaces { 489 + for _, addr := range iface.IPAddresses { 490 + if addr.Family == upcloud.IPAddressFamilyIPv4 { 491 + switch iface.Type { 492 + case upcloud.IPAddressAccessPublic: 493 + s.PublicIP = addr.Address 494 + case upcloud.IPAddressAccessPrivate: 495 + s.PrivateIP = addr.Address 496 + } 497 + } 498 + } 499 + } 500 + 501 + return s, nil 502 + } 503 + 504 + func createFirewallRules(ctx context.Context, svc *service.Service, serverUUID, privateCIDR string) error { 505 + networkBase := strings.TrimSuffix(privateCIDR, "/24") 506 + networkBase = strings.TrimSuffix(networkBase, ".0") 507 + 508 + return svc.CreateFirewallRules(ctx, &request.CreateFirewallRulesRequest{ 509 + ServerUUID: serverUUID, 510 + FirewallRules: request.FirewallRuleSlice{ 511 + { 512 + Direction: upcloud.FirewallRuleDirectionIn, 513 + Action: upcloud.FirewallRuleActionAccept, 514 + Family: upcloud.IPAddressFamilyIPv4, 515 + Protocol: upcloud.FirewallRuleProtocolTCP, 516 + DestinationPortStart: "22", 517 + DestinationPortEnd: "22", 518 + Position: 1, 519 + Comment: "Allow SSH", 520 + }, 521 + { 522 + Direction: upcloud.FirewallRuleDirectionIn, 523 + Action: upcloud.FirewallRuleActionAccept, 524 + Family: upcloud.IPAddressFamilyIPv4, 525 + SourceAddressStart: networkBase + ".0", 526 + SourceAddressEnd: networkBase + ".255", 527 + Position: 2, 528 + Comment: "Allow private network", 529 + }, 530 + { 531 + Direction: upcloud.FirewallRuleDirectionIn, 532 + Action: upcloud.FirewallRuleActionDrop, 533 + Position: 3, 534 + Comment: "Drop all other inbound", 535 + }, 536 + }, 537 + }) 538 + } 539 + 540 + func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain string) (*upcloud.LoadBalancer, error) { 541 + lb, err := svc.CreateLoadBalancer(ctx, &request.CreateLoadBalancerRequest{ 542 + Name: naming.LBName(), 543 + Plan: "essentials", 544 + Zone: cfg.Zone, 545 + ConfiguredStatus: upcloud.LoadBalancerConfiguredStatusStarted, 546 + Networks: []request.LoadBalancerNetwork{ 547 + { 548 + Name: "public", 549 + Type: upcloud.LoadBalancerNetworkTypePublic, 550 + Family: upcloud.LoadBalancerAddressFamilyIPv4, 551 + }, 552 + { 553 + Name: "private", 554 + Type: upcloud.LoadBalancerNetworkTypePrivate, 555 + Family: upcloud.LoadBalancerAddressFamilyIPv4, 556 + UUID: networkUUID, 557 + }, 558 + }, 559 + Frontends: []request.LoadBalancerFrontend{ 560 + { 561 + Name: "https", 562 + Mode: upcloud.LoadBalancerModeHTTP, 563 + Port: 443, 564 + DefaultBackend: "appview", 565 + Networks: []upcloud.LoadBalancerFrontendNetwork{ 566 + {Name: "public"}, 567 + }, 568 + Rules: []request.LoadBalancerFrontendRule{ 569 + { 570 + Name: "route-hold", 571 + Priority: 10, 572 + Matchers: []upcloud.LoadBalancerMatcher{ 573 + { 574 + Type: upcloud.LoadBalancerMatcherTypeHost, 575 + Host: &upcloud.LoadBalancerMatcherHost{ 576 + Value: holdDomain, 577 + }, 578 + }, 579 + }, 580 + Actions: []upcloud.LoadBalancerAction{ 581 + { 582 + Type: upcloud.LoadBalancerActionTypeUseBackend, 583 + UseBackend: &upcloud.LoadBalancerActionUseBackend{ 584 + Backend: "hold", 585 + }, 586 + }, 587 + }, 588 + }, 589 + }, 590 + }, 591 + { 592 + Name: "http-redirect", 593 + Mode: upcloud.LoadBalancerModeHTTP, 594 + Port: 80, 595 + DefaultBackend: "appview", 596 + Networks: []upcloud.LoadBalancerFrontendNetwork{ 597 + {Name: "public"}, 598 + }, 599 + Rules: []request.LoadBalancerFrontendRule{ 600 + { 601 + Name: "redirect-https", 602 + Priority: 10, 603 + Matchers: []upcloud.LoadBalancerMatcher{ 604 + { 605 + Type: upcloud.LoadBalancerMatcherTypeSrcPort, 606 + SrcPort: &upcloud.LoadBalancerMatcherInteger{ 607 + Method: upcloud.LoadBalancerIntegerMatcherMethodEqual, 608 + Value: 80, 609 + }, 610 + }, 611 + }, 612 + Actions: []upcloud.LoadBalancerAction{ 613 + { 614 + Type: upcloud.LoadBalancerActionTypeHTTPRedirect, 615 + HTTPRedirect: &upcloud.LoadBalancerActionHTTPRedirect{ 616 + Scheme: upcloud.LoadBalancerActionHTTPRedirectSchemeHTTPS, 617 + }, 618 + }, 619 + }, 620 + }, 621 + }, 622 + }, 623 + }, 624 + Resolvers: []request.LoadBalancerResolver{}, 625 + Backends: []request.LoadBalancerBackend{ 626 + { 627 + Name: "appview", 628 + Members: []request.LoadBalancerBackendMember{ 629 + { 630 + Name: "appview-1", 631 + Type: upcloud.LoadBalancerBackendMemberTypeStatic, 632 + IP: appviewIP, 633 + Port: 5000, 634 + Weight: 100, 635 + MaxSessions: 1000, 636 + Enabled: true, 637 + }, 638 + }, 639 + Properties: &upcloud.LoadBalancerBackendProperties{ 640 + HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, 641 + HealthCheckURL: "/health", 642 + }, 643 + }, 644 + { 645 + Name: "hold", 646 + Members: []request.LoadBalancerBackendMember{ 647 + { 648 + Name: "hold-1", 649 + Type: upcloud.LoadBalancerBackendMemberTypeStatic, 650 + IP: holdIP, 651 + Port: 8080, 652 + Weight: 100, 653 + MaxSessions: 1000, 654 + Enabled: true, 655 + }, 656 + }, 657 + Properties: &upcloud.LoadBalancerBackendProperties{ 658 + HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, 659 + HealthCheckURL: "/xrpc/_health", 660 + }, 661 + }, 662 + }, 663 + }) 664 + if err != nil { 665 + return nil, err 666 + } 667 + 668 + return lb, nil 669 + } 670 + 671 + // ensureLBCertificates reconciles TLS certificate bundles on the load balancer. 672 + // It skips domains that already have a TLS config attached and creates missing ones. 673 + func ensureLBCertificates(ctx context.Context, svc *service.Service, lbUUID string, tlsDomains []string) error { 674 + lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{UUID: lbUUID}) 675 + if err != nil { 676 + return fmt.Errorf("get load balancer: %w", err) 677 + } 678 + 679 + // Build set of existing TLS config names on the "https" frontend 680 + existing := make(map[string]bool) 681 + for _, fe := range lb.Frontends { 682 + if fe.Name == "https" { 683 + for _, tc := range fe.TLSConfigs { 684 + existing[tc.Name] = true 685 + } 686 + } 687 + } 688 + 689 + for _, domain := range tlsDomains { 690 + certName := "tls-" + strings.ReplaceAll(domain, ".", "-") 691 + if existing[certName] { 692 + fmt.Printf(" TLS certificate: %s (exists)\n", domain) 693 + continue 694 + } 695 + 696 + bundle, err := svc.CreateLoadBalancerCertificateBundle(ctx, &request.CreateLoadBalancerCertificateBundleRequest{ 697 + Type: upcloud.LoadBalancerCertificateBundleTypeDynamic, 698 + Name: certName, 699 + KeyType: "ecdsa", 700 + Hostnames: []string{domain}, 701 + }) 702 + if err != nil { 703 + return fmt.Errorf("create TLS cert for %s: %w", domain, err) 704 + } 705 + 706 + _, err = svc.CreateLoadBalancerFrontendTLSConfig(ctx, &request.CreateLoadBalancerFrontendTLSConfigRequest{ 707 + ServiceUUID: lbUUID, 708 + FrontendName: "https", 709 + Config: request.LoadBalancerFrontendTLSConfig{ 710 + Name: certName, 711 + CertificateBundleUUID: bundle.UUID, 712 + }, 713 + }) 714 + if err != nil { 715 + return fmt.Errorf("attach TLS cert %s to frontend: %w", domain, err) 716 + } 717 + fmt.Printf(" TLS certificate: %s\n", domain) 718 + } 719 + 720 + return nil 721 + } 722 + 723 + // lookupObjectStorage discovers details of an existing Managed Object Storage. 724 + func lookupObjectStorage(ctx context.Context, svc *service.Service, uuid string) (ObjectStorageState, error) { 725 + storage, err := svc.GetManagedObjectStorage(ctx, &request.GetManagedObjectStorageRequest{ 726 + UUID: uuid, 727 + }) 728 + if err != nil { 729 + return ObjectStorageState{}, fmt.Errorf("get object storage %s: %w", uuid, err) 730 + } 731 + 732 + var endpoint string 733 + for _, ep := range storage.Endpoints { 734 + if ep.DomainName != "" { 735 + endpoint = "https://" + ep.DomainName 736 + break 737 + } 738 + } 739 + 740 + var bucket string 741 + buckets, err := svc.GetManagedObjectStorageBucketMetrics(ctx, &request.GetManagedObjectStorageBucketMetricsRequest{ 742 + ServiceUUID: uuid, 743 + }) 744 + if err == nil { 745 + for _, b := range buckets { 746 + if !b.Deleted { 747 + bucket = b.Name 748 + break 749 + } 750 + } 751 + } 752 + 753 + var accessKeyID string 754 + users, err := svc.GetManagedObjectStorageUsers(ctx, &request.GetManagedObjectStorageUsersRequest{ 755 + ServiceUUID: uuid, 756 + }) 757 + if err == nil { 758 + for _, u := range users { 759 + for _, k := range u.AccessKeys { 760 + if k.Status == "Active" { 761 + accessKeyID = k.AccessKeyID 762 + break 763 + } 764 + } 765 + if accessKeyID != "" { 766 + break 767 + } 768 + } 769 + } 770 + 771 + return ObjectStorageState{ 772 + UUID: uuid, 773 + Endpoint: endpoint, 774 + Region: storage.Region, 775 + Bucket: bucket, 776 + AccessKeyID: accessKeyID, 777 + }, nil 778 + } 779 + 780 + func findDebianTemplate(ctx context.Context, svc *service.Service) (string, error) { 781 + storages, err := svc.GetStorages(ctx, &request.GetStoragesRequest{ 782 + Type: "template", 783 + }) 784 + if err != nil { 785 + return "", fmt.Errorf("list templates: %w", err) 786 + } 787 + 788 + var debian13, debian12 string 789 + for _, s := range storages.Storages { 790 + title := strings.ToLower(s.Title) 791 + if strings.Contains(title, "debian") { 792 + if strings.Contains(title, "13") || strings.Contains(title, "trixie") { 793 + debian13 = s.UUID 794 + } else if strings.Contains(title, "12") || strings.Contains(title, "bookworm") { 795 + debian12 = s.UUID 796 + } 797 + } 798 + } 799 + 800 + if debian13 != "" { 801 + return debian13, nil 802 + } 803 + if debian12 != "" { 804 + fmt.Println(" Debian 13 not available, using Debian 12") 805 + return debian12, nil 806 + } 807 + 808 + return "", fmt.Errorf("no Debian template found — check UpCloud template list") 809 + } 810 + 811 + const cloudInitPath = "/var/lib/cloud/instance/scripts/part-001" 812 + 813 + // syncCloudInit compares a locally-generated cloud-init script against what's 814 + // on the server. If they differ (or the remote is missing), it prompts the 815 + // user and re-runs the script over SSH. 816 + func syncCloudInit(name, ip, localScript string) error { 817 + // Fetch the remote script 818 + remoteScript, err := runSSH(ip, fmt.Sprintf("cat %s 2>/dev/null || echo '__MISSING__'", cloudInitPath), false) 819 + if err != nil { 820 + fmt.Printf(" cloud-init: could not reach %s (%v)\n", name, err) 821 + return nil 822 + } 823 + remoteScript = strings.TrimSpace(remoteScript) 824 + 825 + if remoteScript == "__MISSING__" { 826 + fmt.Printf(" cloud-init: not found on %s (server may need initial setup)\n", name) 827 + } else { 828 + localHash := fmt.Sprintf("%x", sha256.Sum256([]byte(localScript))) 829 + remoteHash := fmt.Sprintf("%x", sha256.Sum256([]byte(remoteScript))) 830 + if localHash == remoteHash { 831 + fmt.Printf(" cloud-init: up to date\n") 832 + return nil 833 + } 834 + fmt.Printf(" cloud-init: differs from local\n") 835 + } 836 + 837 + fmt.Printf(" Re-run cloud-init on %s? [Y/n] ", name) 838 + scanner := bufio.NewScanner(os.Stdin) 839 + scanner.Scan() 840 + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) 841 + if answer != "" && answer != "y" && answer != "yes" { 842 + fmt.Printf(" Skipped\n") 843 + return nil 844 + } 845 + 846 + fmt.Printf(" Running cloud-init on %s (%s)... (this may take several minutes)\n", name, ip) 847 + output, err := runSSH(ip, localScript, true) 848 + if err != nil { 849 + fmt.Printf(" ERROR: %v\n", err) 850 + fmt.Printf(" Output:\n%s\n", output) 851 + return fmt.Errorf("cloud-init %s failed", name) 852 + } 853 + 854 + fmt.Printf(" %s: cloud-init complete\n", name) 855 + return nil 856 + }
+91
deploy/upcloud/state.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "runtime" 9 + ) 10 + 11 + // InfraState persists infrastructure resource UUIDs between commands. 12 + type InfraState struct { 13 + Zone string `json:"zone"` 14 + ClientName string `json:"client_name,omitempty"` 15 + RepoBranch string `json:"repo_branch,omitempty"` 16 + Network StateRef `json:"network"` 17 + Appview ServerState `json:"appview"` 18 + Hold ServerState `json:"hold"` 19 + LB StateRef `json:"loadbalancer"` 20 + ObjectStorage ObjectStorageState `json:"object_storage"` 21 + } 22 + 23 + // Naming returns a Naming helper, defaulting to "seamark" if ClientName is empty. 24 + func (s *InfraState) Naming() Naming { 25 + name := s.ClientName 26 + if name == "" { 27 + name = "seamark" 28 + } 29 + return Naming{ClientName: name} 30 + } 31 + 32 + // Branch returns the repo branch, defaulting to "main" if empty. 33 + func (s *InfraState) Branch() string { 34 + if s.RepoBranch == "" { 35 + return "main" 36 + } 37 + return s.RepoBranch 38 + } 39 + 40 + type StateRef struct { 41 + UUID string `json:"uuid"` 42 + } 43 + 44 + type ServerState struct { 45 + UUID string `json:"server_uuid"` 46 + PublicIP string `json:"public_ip"` 47 + PrivateIP string `json:"private_ip"` 48 + } 49 + 50 + type ObjectStorageState struct { 51 + UUID string `json:"uuid"` 52 + Endpoint string `json:"endpoint"` 53 + Region string `json:"region"` 54 + Bucket string `json:"bucket"` 55 + AccessKeyID string `json:"access_key_id"` 56 + } 57 + 58 + func statePath() string { 59 + _, thisFile, _, _ := runtime.Caller(0) 60 + return filepath.Join(filepath.Dir(thisFile), "state.json") 61 + } 62 + 63 + func loadState() (*InfraState, error) { 64 + data, err := os.ReadFile(statePath()) 65 + if err != nil { 66 + return nil, fmt.Errorf("read state.json: %w (run 'provision' first)", err) 67 + } 68 + var st InfraState 69 + if err := json.Unmarshal(data, &st); err != nil { 70 + return nil, fmt.Errorf("parse state.json: %w", err) 71 + } 72 + return &st, nil 73 + } 74 + 75 + func saveState(st *InfraState) error { 76 + data, err := json.MarshalIndent(st, "", " ") 77 + if err != nil { 78 + return fmt.Errorf("marshal state: %w", err) 79 + } 80 + if err := os.WriteFile(statePath(), data, 0644); err != nil { 81 + return fmt.Errorf("write state.json: %w", err) 82 + } 83 + return nil 84 + } 85 + 86 + func deleteState() error { 87 + if err := os.Remove(statePath()); err != nil && !os.IsNotExist(err) { 88 + return fmt.Errorf("remove state.json: %w", err) 89 + } 90 + return nil 91 + }
+120
deploy/upcloud/status.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" 10 + "github.com/spf13/cobra" 11 + ) 12 + 13 + var statusCmd = &cobra.Command{ 14 + Use: "status", 15 + Short: "Show infrastructure state and health", 16 + Args: cobra.NoArgs, 17 + RunE: func(cmd *cobra.Command, args []string) error { 18 + token, _ := cmd.Root().PersistentFlags().GetString("token") 19 + return cmdStatus(token) 20 + }, 21 + } 22 + 23 + func init() { 24 + rootCmd.AddCommand(statusCmd) 25 + } 26 + 27 + func cmdStatus(token string) error { 28 + state, err := loadState() 29 + if err != nil { 30 + return err 31 + } 32 + 33 + naming := state.Naming() 34 + 35 + svc, err := newService(token) 36 + if err != nil { 37 + return err 38 + } 39 + 40 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 41 + defer cancel() 42 + 43 + fmt.Printf("Zone: %s\n\n", state.Zone) 44 + 45 + // Server status 46 + for _, s := range []struct { 47 + name string 48 + ss ServerState 49 + serviceName string 50 + healthURL string 51 + }{ 52 + {"Appview", state.Appview, naming.Appview(), "http://localhost:5000/health"}, 53 + {"Hold", state.Hold, naming.Hold(), "http://localhost:8080/xrpc/_health"}, 54 + } { 55 + fmt.Printf("%-8s UUID: %s\n", s.name, s.ss.UUID) 56 + fmt.Printf(" Public: %s\n", s.ss.PublicIP) 57 + fmt.Printf(" Private: %s\n", s.ss.PrivateIP) 58 + 59 + if s.ss.UUID != "" { 60 + details, err := svc.GetServerDetails(ctx, &request.GetServerDetailsRequest{ 61 + UUID: s.ss.UUID, 62 + }) 63 + if err != nil { 64 + fmt.Printf(" State: error (%v)\n", err) 65 + } else { 66 + fmt.Printf(" State: %s\n", details.State) 67 + } 68 + } 69 + 70 + // SSH health check 71 + if s.ss.PublicIP != "" { 72 + output, err := runSSH(s.ss.PublicIP, fmt.Sprintf( 73 + "systemctl is-active %s 2>/dev/null || echo 'inactive'; curl -sf %s > /dev/null 2>&1 && echo 'health:ok' || echo 'health:fail'", 74 + s.serviceName, s.healthURL, 75 + ), false) 76 + if err != nil { 77 + fmt.Printf(" Service: unreachable\n") 78 + } else { 79 + lines := strings.Split(strings.TrimSpace(output), "\n") 80 + for _, line := range lines { 81 + line = strings.TrimSpace(line) 82 + if line == "active" || line == "inactive" { 83 + fmt.Printf(" Service: %s\n", line) 84 + } else if strings.HasPrefix(line, "health:") { 85 + fmt.Printf(" Health: %s\n", strings.TrimPrefix(line, "health:")) 86 + } 87 + } 88 + } 89 + } 90 + fmt.Println() 91 + } 92 + 93 + // LB status 94 + if state.LB.UUID != "" { 95 + fmt.Printf("Load Balancer: %s\n", state.LB.UUID) 96 + lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{ 97 + UUID: state.LB.UUID, 98 + }) 99 + if err != nil { 100 + fmt.Printf(" State: error (%v)\n", err) 101 + } else { 102 + fmt.Printf(" State: %s\n", lb.OperationalState) 103 + for _, n := range lb.Networks { 104 + fmt.Printf(" Network (%s): %s\n", n.Type, n.DNSName) 105 + } 106 + } 107 + } 108 + 109 + fmt.Printf("\nNetwork: %s\n", state.Network.UUID) 110 + 111 + if state.ObjectStorage.UUID != "" { 112 + fmt.Printf("\nObject Storage: %s\n", state.ObjectStorage.UUID) 113 + fmt.Printf(" Endpoint: %s\n", state.ObjectStorage.Endpoint) 114 + fmt.Printf(" Region: %s\n", state.ObjectStorage.Region) 115 + fmt.Printf(" Bucket: %s\n", state.ObjectStorage.Bucket) 116 + fmt.Printf(" Access Key: %s\n", state.ObjectStorage.AccessKeyID) 117 + } 118 + 119 + return nil 120 + }
+25
deploy/upcloud/systemd/appview.service.tmpl
··· 1 + [Unit] 2 + Description={{.DisplayName}} AppView (Registry + Web UI) 3 + After=network-online.target 4 + Wants=network-online.target 5 + 6 + [Service] 7 + Type=simple 8 + User={{.User}} 9 + Group={{.User}} 10 + ExecStart={{.BinaryPath}} serve --config {{.ConfigPath}} 11 + Restart=on-failure 12 + RestartSec=5 13 + 14 + ReadWritePaths={{.DataDir}} 15 + ProtectSystem=strict 16 + ProtectHome=yes 17 + NoNewPrivileges=yes 18 + PrivateTmp=yes 19 + 20 + StandardOutput=journal 21 + StandardError=journal 22 + SyslogIdentifier={{.ServiceName}} 23 + 24 + [Install] 25 + WantedBy=multi-user.target
+25
deploy/upcloud/systemd/hold.service.tmpl
··· 1 + [Unit] 2 + Description={{.DisplayName}} Hold (Storage Service) 3 + After=network-online.target 4 + Wants=network-online.target 5 + 6 + [Service] 7 + Type=simple 8 + User={{.User}} 9 + Group={{.User}} 10 + ExecStart={{.BinaryPath}} serve --config {{.ConfigPath}} 11 + Restart=on-failure 12 + RestartSec=5 13 + 14 + ReadWritePaths={{.DataDir}} 15 + ProtectSystem=strict 16 + ProtectHome=yes 17 + NoNewPrivileges=yes 18 + PrivateTmp=yes 19 + 20 + StandardOutput=journal 21 + StandardError=journal 22 + SyslogIdentifier={{.ServiceName}} 23 + 24 + [Install] 25 + WantedBy=multi-user.target
+121
deploy/upcloud/teardown.go
··· 1 + package main 2 + 3 + import ( 4 + "bufio" 5 + "context" 6 + "fmt" 7 + "os" 8 + "strings" 9 + "time" 10 + 11 + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" 12 + "github.com/spf13/cobra" 13 + ) 14 + 15 + var teardownCmd = &cobra.Command{ 16 + Use: "teardown", 17 + Short: "Destroy all infrastructure", 18 + Args: cobra.NoArgs, 19 + RunE: func(cmd *cobra.Command, args []string) error { 20 + token, _ := cmd.Root().PersistentFlags().GetString("token") 21 + return cmdTeardown(token) 22 + }, 23 + } 24 + 25 + func init() { 26 + rootCmd.AddCommand(teardownCmd) 27 + } 28 + 29 + func cmdTeardown(token string) error { 30 + state, err := loadState() 31 + if err != nil { 32 + return err 33 + } 34 + 35 + naming := state.Naming() 36 + 37 + // Confirmation prompt 38 + fmt.Printf("This will DESTROY all %s infrastructure:\n", naming.DisplayName()) 39 + fmt.Printf(" Zone: %s\n", state.Zone) 40 + fmt.Printf(" Appview: %s (%s)\n", state.Appview.UUID, state.Appview.PublicIP) 41 + fmt.Printf(" Hold: %s (%s)\n", state.Hold.UUID, state.Hold.PublicIP) 42 + fmt.Printf(" Network: %s\n", state.Network.UUID) 43 + fmt.Printf(" LB: %s\n", state.LB.UUID) 44 + fmt.Println() 45 + fmt.Print("Type 'yes' to confirm: ") 46 + 47 + scanner := bufio.NewScanner(os.Stdin) 48 + scanner.Scan() 49 + if strings.TrimSpace(scanner.Text()) != "yes" { 50 + fmt.Println("Aborted.") 51 + return nil 52 + } 53 + 54 + svc, err := newService(token) 55 + if err != nil { 56 + return err 57 + } 58 + 59 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 60 + defer cancel() 61 + 62 + // Delete LB first (depends on network) 63 + if state.LB.UUID != "" { 64 + fmt.Printf("Deleting load balancer %s...\n", state.LB.UUID) 65 + if err := svc.DeleteLoadBalancer(ctx, &request.DeleteLoadBalancerRequest{ 66 + UUID: state.LB.UUID, 67 + }); err != nil { 68 + fmt.Printf(" Warning: %v\n", err) 69 + } 70 + } 71 + 72 + // Stop and delete servers (must stop before delete, and delete storage) 73 + for _, s := range []struct { 74 + name string 75 + uuid string 76 + }{ 77 + {"appview", state.Appview.UUID}, 78 + {"hold", state.Hold.UUID}, 79 + } { 80 + if s.uuid == "" { 81 + continue 82 + } 83 + fmt.Printf("Stopping server %s (%s)...\n", s.name, s.uuid) 84 + _, err := svc.StopServer(ctx, &request.StopServerRequest{ 85 + UUID: s.uuid, 86 + }) 87 + if err != nil { 88 + fmt.Printf(" Warning (stop): %v\n", err) 89 + } else { 90 + svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{ 91 + UUID: s.uuid, 92 + DesiredState: "stopped", 93 + }) 94 + } 95 + 96 + fmt.Printf("Deleting server %s...\n", s.name) 97 + if err := svc.DeleteServerAndStorages(ctx, &request.DeleteServerAndStoragesRequest{ 98 + UUID: s.uuid, 99 + }); err != nil { 100 + fmt.Printf(" Warning (delete): %v\n", err) 101 + } 102 + } 103 + 104 + // Delete network (after servers are gone) 105 + if state.Network.UUID != "" { 106 + fmt.Printf("Deleting network %s...\n", state.Network.UUID) 107 + if err := svc.DeleteNetwork(ctx, &request.DeleteNetworkRequest{ 108 + UUID: state.Network.UUID, 109 + }); err != nil { 110 + fmt.Printf(" Warning: %v\n", err) 111 + } 112 + } 113 + 114 + // Remove state file 115 + if err := deleteState(); err != nil { 116 + return err 117 + } 118 + 119 + fmt.Println("\nTeardown complete. All infrastructure destroyed.") 120 + return nil 121 + }
+197
deploy/upcloud/update.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "io" 7 + "os" 8 + "os/exec" 9 + "strings" 10 + "time" 11 + 12 + "github.com/spf13/cobra" 13 + ) 14 + 15 + var updateCmd = &cobra.Command{ 16 + Use: "update [target]", 17 + Short: "Deploy updates to servers", 18 + Args: cobra.MaximumNArgs(1), 19 + ValidArgs: []string{"all", "appview", "hold"}, 20 + RunE: func(cmd *cobra.Command, args []string) error { 21 + target := "all" 22 + if len(args) > 0 { 23 + target = args[0] 24 + } 25 + return cmdUpdate(target) 26 + }, 27 + } 28 + 29 + var sshCmd = &cobra.Command{ 30 + Use: "ssh <target>", 31 + Short: "SSH into a server", 32 + Args: cobra.ExactArgs(1), 33 + ValidArgs: []string{"appview", "hold"}, 34 + RunE: func(cmd *cobra.Command, args []string) error { 35 + return cmdSSH(args[0]) 36 + }, 37 + } 38 + 39 + func init() { 40 + rootCmd.AddCommand(updateCmd) 41 + rootCmd.AddCommand(sshCmd) 42 + } 43 + 44 + func cmdUpdate(target string) error { 45 + state, err := loadState() 46 + if err != nil { 47 + return err 48 + } 49 + 50 + naming := state.Naming() 51 + branch := state.Branch() 52 + 53 + goVersion, err := requiredGoVersion() 54 + if err != nil { 55 + return err 56 + } 57 + 58 + targets := map[string]struct { 59 + ip string 60 + binaryName string 61 + buildCmd string 62 + serviceName string 63 + healthURL string 64 + }{ 65 + "appview": { 66 + ip: state.Appview.PublicIP, 67 + binaryName: naming.Appview(), 68 + buildCmd: "appview", 69 + serviceName: naming.Appview(), 70 + healthURL: "http://localhost:5000/health", 71 + }, 72 + "hold": { 73 + ip: state.Hold.PublicIP, 74 + binaryName: naming.Hold(), 75 + buildCmd: "hold", 76 + serviceName: naming.Hold(), 77 + healthURL: "http://localhost:8080/xrpc/_health", 78 + }, 79 + } 80 + 81 + var toUpdate []string 82 + switch target { 83 + case "all": 84 + toUpdate = []string{"appview", "hold"} 85 + case "appview", "hold": 86 + toUpdate = []string{target} 87 + default: 88 + return fmt.Errorf("unknown target: %s (use: all, appview, hold)", target) 89 + } 90 + 91 + for _, name := range toUpdate { 92 + t := targets[name] 93 + fmt.Printf("Updating %s (%s)...\n", name, t.ip) 94 + 95 + updateScript := fmt.Sprintf(`set -euo pipefail 96 + export PATH=$PATH:/usr/local/go/bin 97 + 98 + # Update Go if needed 99 + CURRENT_GO=$(go version 2>/dev/null | grep -oP 'go\K[0-9.]+' || echo "none") 100 + REQUIRED_GO="%s" 101 + if [ "$CURRENT_GO" != "$REQUIRED_GO" ]; then 102 + echo "Updating Go: $CURRENT_GO -> $REQUIRED_GO" 103 + rm -rf /usr/local/go 104 + curl -fsSL https://go.dev/dl/go${REQUIRED_GO}.linux-amd64.tar.gz | tar -C /usr/local -xz 105 + fi 106 + 107 + cd %s 108 + git pull origin %s 109 + npm ci 110 + go generate ./... 111 + CGO_ENABLED=1 go build \ 112 + -ldflags="-s -w -linkmode external -extldflags '-static'" \ 113 + -tags sqlite_omit_load_extension -trimpath \ 114 + -o bin/%s ./cmd/%s 115 + systemctl restart %s 116 + 117 + sleep 2 118 + curl -sf %s > /dev/null && echo "HEALTH_OK" || echo "HEALTH_FAIL" 119 + `, goVersion, naming.InstallDir(), branch, t.binaryName, t.buildCmd, t.serviceName, t.healthURL) 120 + 121 + output, err := runSSH(t.ip, updateScript, true) 122 + if err != nil { 123 + fmt.Printf(" ERROR: %v\n", err) 124 + fmt.Printf(" Output: %s\n", output) 125 + return fmt.Errorf("update %s failed", name) 126 + } 127 + 128 + if strings.Contains(output, "HEALTH_OK") { 129 + fmt.Printf(" %s: updated and healthy\n", name) 130 + } else if strings.Contains(output, "HEALTH_FAIL") { 131 + fmt.Printf(" %s: updated but health check failed!\n", name) 132 + fmt.Printf(" Check: ssh root@%s journalctl -u %s -n 50\n", t.ip, t.serviceName) 133 + } else { 134 + fmt.Printf(" %s: updated (health check inconclusive)\n", name) 135 + } 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func cmdSSH(target string) error { 142 + state, err := loadState() 143 + if err != nil { 144 + return err 145 + } 146 + 147 + var ip string 148 + switch target { 149 + case "appview": 150 + ip = state.Appview.PublicIP 151 + case "hold": 152 + ip = state.Hold.PublicIP 153 + default: 154 + return fmt.Errorf("unknown target: %s (use: appview, hold)", target) 155 + } 156 + 157 + fmt.Printf("Connecting to %s (%s)...\n", target, ip) 158 + cmd := exec.Command("ssh", 159 + "-o", "StrictHostKeyChecking=accept-new", 160 + "root@"+ip, 161 + ) 162 + cmd.Stdin = os.Stdin 163 + cmd.Stdout = os.Stdout 164 + cmd.Stderr = os.Stderr 165 + return cmd.Run() 166 + } 167 + 168 + func runSSH(ip, script string, stream bool) (string, error) { 169 + cmd := exec.Command("ssh", 170 + "-o", "StrictHostKeyChecking=accept-new", 171 + "-o", "ConnectTimeout=10", 172 + "root@"+ip, 173 + "bash -s", 174 + ) 175 + cmd.Stdin = strings.NewReader(script) 176 + 177 + var buf bytes.Buffer 178 + if stream { 179 + cmd.Stdout = io.MultiWriter(os.Stdout, &buf) 180 + cmd.Stderr = io.MultiWriter(os.Stderr, &buf) 181 + } else { 182 + cmd.Stdout = &buf 183 + cmd.Stderr = &buf 184 + } 185 + 186 + // Give builds up to 10 minutes 187 + done := make(chan error, 1) 188 + go func() { done <- cmd.Run() }() 189 + 190 + select { 191 + case err := <-done: 192 + return buf.String(), err 193 + case <-time.After(10 * time.Minute): 194 + cmd.Process.Kill() 195 + return buf.String(), fmt.Errorf("SSH command timed out after 10 minutes") 196 + } 197 + }
+6 -3
go.mod
··· 17 17 github.com/goki/freetype v1.0.5 18 18 github.com/golang-jwt/jwt/v5 v5.3.1 19 19 github.com/google/uuid v1.6.0 20 - github.com/gorilla/websocket v1.5.3 20 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 21 21 github.com/ipfs/go-block-format v0.2.3 22 22 github.com/ipfs/go-cid v0.6.0 23 23 github.com/ipfs/go-datastore v0.9.0 ··· 48 48 ) 49 49 50 50 require ( 51 + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect 51 52 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 52 53 github.com/ajg/form v1.6.1 // indirect 53 54 github.com/antlr4-go/antlr/v4 v4.13.0 // indirect ··· 73 74 github.com/cenkalti/backoff/v5 v5.0.3 // indirect 74 75 github.com/cespare/xxhash/v2 v2.3.0 // indirect 75 76 github.com/coreos/go-systemd/v22 v22.7.0 // indirect 76 - github.com/davecgh/go-spew v1.1.1 // indirect 77 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 77 78 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 78 79 github.com/docker/docker-credential-helpers v0.9.5 // indirect 79 80 github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect 80 81 github.com/docker/go-metrics v0.0.1 // indirect 82 + github.com/fatih/color v1.18.0 // indirect 81 83 github.com/felixge/httpsnoop v1.0.4 // indirect 82 84 github.com/fsnotify/fsnotify v1.9.0 // indirect 83 85 github.com/gammazero/chanqueue v1.1.1 // indirect ··· 115 117 github.com/jmespath/go-jmespath v0.4.0 // indirect 116 118 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 117 119 github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect 120 + github.com/mattn/go-colorable v0.1.14 // indirect 118 121 github.com/mattn/go-isatty v0.0.20 // indirect 119 122 github.com/minio/sha256-simd v1.0.1 // indirect 120 123 github.com/mr-tron/base58 v1.2.0 // indirect ··· 127 130 github.com/opencontainers/image-spec v1.1.1 // indirect 128 131 github.com/opentracing/opentracing-go v1.2.0 // indirect 129 132 github.com/pelletier/go-toml/v2 v2.2.4 // indirect 130 - github.com/pmezard/go-difflib v1.0.0 // indirect 133 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 131 134 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 132 135 github.com/prometheus/client_golang v1.23.2 // indirect 133 136 github.com/prometheus/client_model v0.6.2 // indirect
+6 -12
go.sum
··· 1 - github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8 h1:d+pBUmsteW5tM87xmVXHZ4+LibHRFn40SPAoZJOg2ak= 2 - github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h1:i9fr2JpcEcY/IHEvzCM3qXUZYOQHgR89dt4es1CgMhc= 1 + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= 3 2 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 3 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 5 4 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= ··· 76 75 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 77 76 github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= 78 77 github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= 79 - github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= 80 - github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= 81 78 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 82 - github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 83 79 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 80 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 84 81 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 85 82 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 86 83 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= ··· 97 94 github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= 98 95 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 99 96 github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 100 - github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 101 - github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 97 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 102 98 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 103 99 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 104 100 github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= ··· 164 160 github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= 165 161 github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 166 162 github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 167 - github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 168 - github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 163 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 169 164 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= 170 165 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= 171 166 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= ··· 295 290 github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= 296 291 github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM= 297 292 github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= 298 - github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 299 - github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 293 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 300 294 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 301 295 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 302 296 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= ··· 349 343 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 350 344 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 351 345 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 352 - github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 353 346 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 347 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 354 348 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 355 349 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 356 350 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+1
go.work
··· 2 2 3 3 use ( 4 4 . 5 + ./deploy/upcloud 5 6 ./scanner 6 7 )
+7 -1
go.work.sum
··· 173 173 github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= 174 174 github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= 175 175 github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 176 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 177 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 176 178 github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 177 179 github.com/checkpoint-restore/checkpointctl v1.4.0/go.mod h1:ynQ52zQBazgcTZuxpwTFzRinIcAf0haDTC1X1LA/FKA= 178 180 github.com/checkpoint-restore/go-criu/v7 v7.2.0/go.mod h1:u0LCWLg0w4yqqu14aXhiB4YD3a1qd8EcCEg7vda5dwo= 179 181 github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= 180 182 github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= 183 + github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= 181 184 github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= 182 185 github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= 183 186 github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= ··· 451 454 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 452 455 golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 453 456 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 457 + golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 454 458 golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 459 + golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 455 460 golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 461 + golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 456 462 golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 457 463 golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= 458 - golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 464 + golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 459 465 golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 460 466 golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 461 467 golang.org/x/tools v0.0.0-20200113040837-eac381796e91/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+5 -5
pkg/appview/config.go
··· 57 57 // Short name used in page titles and browser tabs. 58 58 ClientShortName string `yaml:"client_short_name" comment:"Short name used in page titles and browser tabs."` 59 59 60 - // Separate domain for OCI registry API. 61 - RegistryDomain string `yaml:"registry_domain" comment:"Separate domain for OCI registry API (e.g. \"buoy.cr\"). Browser visits redirect to BaseURL."` 60 + // Separate domains for OCI registry API. First entry is the primary (used for JWT service name and UI display). 61 + RegistryDomains []string `yaml:"registry_domains" comment:"Separate domains for OCI registry API (e.g. [\"buoy.cr\"]). First is primary. Browser visits redirect to BaseURL."` 62 62 } 63 63 64 64 // UIConfig defines web UI settings ··· 145 145 v.SetDefault("server.client_name", "AT Container Registry") 146 146 v.SetDefault("server.client_short_name", "ATCR") 147 147 v.SetDefault("server.oauth_key_path", "/var/lib/atcr/oauth/client.key") 148 - v.SetDefault("server.registry_domain", "") 148 + v.SetDefault("server.registry_domains", []string{}) 149 149 150 150 // UI defaults 151 151 v.SetDefault("ui.database_path", "/var/lib/atcr/ui.db") ··· 241 241 242 242 // deriveServiceName extracts the JWT service name from the config. 243 243 func deriveServiceName(cfg *Config) string { 244 - if cfg.Server.RegistryDomain != "" { 245 - return cfg.Server.RegistryDomain 244 + if len(cfg.Server.RegistryDomains) > 0 { 245 + return cfg.Server.RegistryDomains[0] 246 246 } 247 247 return getServiceName(cfg.Server.BaseURL) 248 248 }
+2
pkg/appview/db/hold_store_test.go
··· 87 87 } 88 88 // Limit to single connection to avoid race conditions in tests 89 89 db.SetMaxOpenConns(1) 90 + // Clean slate: shared-cache in-memory DB may retain data from prior subtests 91 + db.Exec("DELETE FROM hold_captain_records") 90 92 t.Cleanup(func() { db.Close() }) 91 93 return db 92 94 }
+25 -9
pkg/appview/server.go
··· 240 240 mainRouter.Use(routes.CORSMiddleware()) 241 241 242 242 // Registry domain redirect middleware 243 - if cfg.Server.RegistryDomain != "" { 244 - mainRouter.Use(RegistryDomainRedirect(cfg.Server.RegistryDomain, cfg.Server.BaseURL)) 243 + if len(cfg.Server.RegistryDomains) > 0 { 244 + mainRouter.Use(RegistryDomainRedirect(cfg.Server.RegistryDomains, cfg.Server.BaseURL)) 245 245 slog.Info("Registry domain redirect enabled", 246 - "registry_domain", cfg.Server.RegistryDomain, 246 + "registry_domains", cfg.Server.RegistryDomains, 247 247 "ui_base_url", cfg.Server.BaseURL) 248 248 } 249 249 ··· 263 263 OAuthStore: s.OAuthStore, 264 264 Refresher: s.Refresher, 265 265 BaseURL: baseURL, 266 - RegistryDomain: cfg.Server.RegistryDomain, 266 + RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains), 267 267 DeviceStore: s.DeviceStore, 268 268 HealthChecker: s.HealthChecker, 269 269 ReadmeFetcher: s.ReadmeFetcher, ··· 499 499 mainRouter.Get("/health", func(w http.ResponseWriter, r *http.Request) { 500 500 w.Header().Set("Content-Type", "application/json") 501 501 w.WriteHeader(http.StatusOK) 502 - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 502 + if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil { 503 + http.Error(w, "encode error", http.StatusInternalServerError) 504 + return 505 + } 503 506 }) 504 507 505 508 // Register credential helper version API (public endpoint) ··· 577 580 ) 578 581 } 579 582 580 - // RegistryDomainRedirect redirects all non-registry requests from the registry 581 - // domain to the UI domain. Only /v2 and /v2/* pass through for Docker clients. 583 + // RegistryDomainRedirect redirects all non-registry requests from registry 584 + // domains to the UI domain. Only /v2 and /v2/* pass through for Docker clients. 582 585 // Uses 307 (Temporary Redirect) to preserve POST method/body. 583 - func RegistryDomainRedirect(registryDomain, uiBaseURL string) func(http.Handler) http.Handler { 586 + func RegistryDomainRedirect(registryDomains []string, uiBaseURL string) func(http.Handler) http.Handler { 587 + domains := make(map[string]bool, len(registryDomains)) 588 + for _, d := range registryDomains { 589 + domains[d] = true 590 + } 591 + 584 592 return func(next http.Handler) http.Handler { 585 593 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 586 594 host := r.Host ··· 588 596 host = host[:idx] 589 597 } 590 598 591 - if host == registryDomain { 599 + if domains[host] { 592 600 path := r.URL.Path 593 601 if path == "/v2" || path == "/v2/" || strings.HasPrefix(path, "/v2/") { 594 602 next.ServeHTTP(w, r) ··· 603 611 next.ServeHTTP(w, r) 604 612 }) 605 613 } 614 + } 615 + 616 + // primaryRegistryDomain returns the first registry domain, or empty string if none. 617 + func primaryRegistryDomain(domains []string) string { 618 + if len(domains) > 0 { 619 + return domains[0] 620 + } 621 + return "" 606 622 } 607 623 608 624 // initializeJetstream initializes the Jetstream workers for real-time events and backfill.
+16
pkg/config/marshal.go
··· 131 131 return mapToNode(v) 132 132 } 133 133 134 + // Slice → yaml sequence 135 + if v.Kind() == reflect.Slice { 136 + seq := &yaml.Node{ 137 + Kind: yaml.SequenceNode, 138 + Tag: "!!seq", 139 + } 140 + for i := 0; i < v.Len(); i++ { 141 + elemNode, err := valueToNode(v.Index(i)) 142 + if err != nil { 143 + return nil, fmt.Errorf("slice index %d: %w", i, err) 144 + } 145 + seq.Content = append(seq.Content, elemNode) 146 + } 147 + return seq, nil 148 + } 149 + 134 150 // Scalar types 135 151 node := &yaml.Node{Kind: yaml.ScalarNode} 136 152 switch v.Kind() {
+1 -1
pkg/hold/billing/handlers.go
··· 114 114 stats, err := h.pdsServer.GetQuotaForUserWithTier(r.Context(), userDID, h.manager.quotaMgr) 115 115 if err == nil { 116 116 info.CurrentUsage = stats.TotalSize 117 - info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced) 117 + info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced) 118 118 info.CurrentLimit = stats.Limit 119 119 120 120 // If no subscription but crew has a tier, show that as current
+7 -8
pkg/hold/db/sqlite_store.go
··· 1 - // Package db contains a vendored from github.com/bluesky-social/indigo/carstore/sqlite_store.go 1 + // Package db contains a vendored from github.com/bluesky-social/indigo/carstore/sqlite_store.go 2 2 // Source: github.com/bluesky-social/indigo@v0.0.0-20260203235305-a86f3ae1f8ec/carstore/ 3 3 // Reason: indigo's carstore hardcodes mattn/go-sqlite3, which conflicts with go-libsql 4 4 // (both bundle SQLite C libraries and cannot coexist in the same binary). 5 5 // 6 6 // This package replaces the mattn driver with go-libsql and removes Prometheus metrics. 7 7 // Once upstream accepts a driver-agnostic constructor, this vendored copy can be removed. 8 - // Modifications: 9 - // - Replaced mattn/go-sqlite3 driver with go-libsql 10 - // - Removed all Prometheus metric counters and .Inc() calls 11 - // - Changed package from 'carstore' to 'db' 12 - // - Added NewSQLiteStoreWithDB constructor for injecting an existing *sql.DB 13 - // - Changed sql.Open("sqlite3", path) to sql.Open("libsql", ...) with proper DSN 14 - 8 + // Modifications: 9 + // - Replaced mattn/go-sqlite3 driver with go-libsql 10 + // - Removed all Prometheus metric counters and .Inc() calls 11 + // - Changed package from 'carstore' to 'db' 12 + // - Added NewSQLiteStoreWithDB constructor for injecting an existing *sql.DB 13 + // - Changed sql.Open("sqlite3", path) to sql.Open("libsql", ...) with proper DSN 15 14 package db 16 15 17 16 import (