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

Configure Feed

Select the types of activity you want to include in your feed.

add new upcloud cli deploy

+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 (