diff --git a/.gitignore b/.gitignore index 9e38707..2f385ad 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,8 @@ tmp/ # Environment configuration .env -# Docker-created quota config (actual config is in deploy/quotas.yaml) -quotas.yaml +# Deploy state (contains server UUIDs and IPs) +deploy/upcloud/state.json # Generated assets (run go generate to rebuild) pkg/appview/licenses/spdx-licenses.json diff --git a/config-appview.example.yaml b/config-appview.example.yaml index 21d4536..97c4b2a 100644 --- a/config-appview.example.yaml +++ b/config-appview.example.yaml @@ -35,8 +35,8 @@ server: client_name: AT Container Registry # Short name used in page titles and browser tabs. client_short_name: ATCR - # Separate domain for OCI registry API (e.g. "buoy.cr"). Browser visits redirect to BaseURL. - registry_domain: "" + # Separate domains for OCI registry API (e.g. ["buoy.cr"]). First is primary. Browser visits redirect to BaseURL. + registry_domains: [] # Web UI settings. ui: # SQLite database for OAuth sessions, stars, pull counts, and device approvals. diff --git a/deploy/upcloud/cloudinit.go b/deploy/upcloud/cloudinit.go new file mode 100644 index 0000000..c30da05 --- /dev/null +++ b/deploy/upcloud/cloudinit.go @@ -0,0 +1,191 @@ +package main + +import ( + "bytes" + _ "embed" + "fmt" + "strings" + "text/template" +) + +//go:embed systemd/appview.service.tmpl +var appviewServiceTmpl string + +//go:embed systemd/hold.service.tmpl +var holdServiceTmpl string + +//go:embed configs/appview.yaml.tmpl +var appviewConfigTmpl string + +//go:embed configs/hold.yaml.tmpl +var holdConfigTmpl string + +//go:embed configs/cloudinit.sh.tmpl +var cloudInitTmpl string + +// ConfigValues holds values injected into config YAML templates. +// Only truly dynamic/computed values belong here — deployment-specific +// values like client_name, owner_did, etc. are literal in the templates. +type ConfigValues struct { + // S3 / Object Storage + S3Endpoint string + S3Region string + S3Bucket string + S3AccessKey string + S3SecretKey string + + // Infrastructure (computed from zone + config) + Zone string // e.g. "us-chi1" + HoldDomain string // e.g. "us-chi1.cove.seamark.dev" + HoldDid string // e.g. "did:web:us-chi1.cove.seamark.dev" + BasePath string // e.g. "/var/lib/seamark" +} + +// renderConfig executes a Go template with the given values. +func renderConfig(tmplStr string, vals *ConfigValues) (string, error) { + t, err := template.New("config").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("parse config template: %w", err) + } + var buf bytes.Buffer + if err := t.Execute(&buf, vals); err != nil { + return "", fmt.Errorf("render config template: %w", err) + } + return buf.String(), nil +} + +// serviceUnitParams holds values for rendering systemd service unit templates. +type serviceUnitParams struct { + DisplayName string // e.g. "Seamark" + User string // e.g. "seamark" + BinaryPath string // e.g. "/opt/seamark/bin/seamark-appview" + ConfigPath string // e.g. "/etc/seamark/appview.yaml" + DataDir string // e.g. "/var/lib/seamark" + ServiceName string // e.g. "seamark-appview" +} + +func renderServiceUnit(tmplStr string, p serviceUnitParams) (string, error) { + t, err := template.New("service").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("parse service template: %w", err) + } + var buf bytes.Buffer + if err := t.Execute(&buf, p); err != nil { + return "", fmt.Errorf("render service template: %w", err) + } + return buf.String(), nil +} + +// generateAppviewCloudInit generates the cloud-init user-data script for the appview server. +func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) { + naming := cfg.Naming() + + configYAML, err := renderConfig(appviewConfigTmpl, vals) + if err != nil { + return "", fmt.Errorf("appview config: %w", err) + } + + serviceUnit, err := renderServiceUnit(appviewServiceTmpl, serviceUnitParams{ + DisplayName: naming.DisplayName(), + User: naming.SystemUser(), + BinaryPath: naming.InstallDir() + "/bin/" + naming.Appview(), + ConfigPath: naming.AppviewConfigPath(), + DataDir: naming.BasePath(), + ServiceName: naming.Appview(), + }) + if err != nil { + return "", fmt.Errorf("appview service unit: %w", err) + } + + return generateCloudInit(cloudInitParams{ + GoVersion: goVersion, + BinaryName: naming.Appview(), + BuildCmd: "appview", + ServiceUnit: serviceUnit, + ConfigYAML: configYAML, + ConfigPath: naming.AppviewConfigPath(), + ServiceName: naming.Appview(), + DataDir: naming.BasePath(), + RepoURL: cfg.RepoURL, + RepoBranch: cfg.RepoBranch, + InstallDir: naming.InstallDir(), + SystemUser: naming.SystemUser(), + ConfigDir: naming.ConfigDir(), + LogFile: naming.LogFile(), + DisplayName: naming.DisplayName(), + }) +} + +// generateHoldCloudInit generates the cloud-init user-data script for the hold server. +func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) { + naming := cfg.Naming() + + configYAML, err := renderConfig(holdConfigTmpl, vals) + if err != nil { + return "", fmt.Errorf("hold config: %w", err) + } + + serviceUnit, err := renderServiceUnit(holdServiceTmpl, serviceUnitParams{ + DisplayName: naming.DisplayName(), + User: naming.SystemUser(), + BinaryPath: naming.InstallDir() + "/bin/" + naming.Hold(), + ConfigPath: naming.HoldConfigPath(), + DataDir: naming.BasePath(), + ServiceName: naming.Hold(), + }) + if err != nil { + return "", fmt.Errorf("hold service unit: %w", err) + } + + return generateCloudInit(cloudInitParams{ + GoVersion: goVersion, + BinaryName: naming.Hold(), + BuildCmd: "hold", + ServiceUnit: serviceUnit, + ConfigYAML: configYAML, + ConfigPath: naming.HoldConfigPath(), + ServiceName: naming.Hold(), + DataDir: naming.BasePath(), + RepoURL: cfg.RepoURL, + RepoBranch: cfg.RepoBranch, + InstallDir: naming.InstallDir(), + SystemUser: naming.SystemUser(), + ConfigDir: naming.ConfigDir(), + LogFile: naming.LogFile(), + DisplayName: naming.DisplayName(), + }) +} + +type cloudInitParams struct { + GoVersion string + BinaryName string + BuildCmd string + ServiceUnit string + ConfigYAML string + ConfigPath string + ServiceName string + DataDir string + RepoURL string + RepoBranch string + InstallDir string + SystemUser string + ConfigDir string + LogFile string + DisplayName string +} + +func generateCloudInit(p cloudInitParams) (string, error) { + // Escape single quotes in embedded content for heredoc safety + p.ServiceUnit = strings.ReplaceAll(p.ServiceUnit, "'", "'\\''") + p.ConfigYAML = strings.ReplaceAll(p.ConfigYAML, "'", "'\\''") + + t, err := template.New("cloudinit").Parse(cloudInitTmpl) + if err != nil { + return "", fmt.Errorf("parse cloudinit template: %w", err) + } + var buf bytes.Buffer + if err := t.Execute(&buf, p); err != nil { + return "", fmt.Errorf("render cloudinit template: %w", err) + } + return buf.String(), nil +} diff --git a/deploy/upcloud/config.go b/deploy/upcloud/config.go new file mode 100644 index 0000000..a4624ac --- /dev/null +++ b/deploy/upcloud/config.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/client" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/service" + "go.yaml.in/yaml/v3" +) + +const ( + repoURL = "https://tangled.org/@evan.jarrett.net/at-container-registry" + repoBranch = "main" + privateNetworkCIDR = "10.0.1.0/24" +) + +// InfraConfig holds infrastructure configuration. +type InfraConfig struct { + Zone string + Plan string + SSHPublicKey string + S3SecretKey string + + // Infrastructure naming — derived from configs/appview.yaml.tmpl. + // Edit that template to rebrand. + ClientName string + BaseDomain string + RegistryDomains []string + RepoURL string + RepoBranch string +} + +// Naming returns a Naming helper derived from ClientName. +func (c *InfraConfig) Naming() Naming { + return Naming{ClientName: c.ClientName} +} + +func loadConfig(zone, plan, sshKeyPath, s3Secret string) (*InfraConfig, error) { + sshKey, err := readSSHPublicKey(sshKeyPath) + if err != nil { + return nil, err + } + + clientName, baseDomain, registryDomains, err := extractFromAppviewTemplate() + if err != nil { + return nil, fmt.Errorf("extract config from template: %w", err) + } + + return &InfraConfig{ + Zone: zone, + Plan: plan, + SSHPublicKey: sshKey, + S3SecretKey: s3Secret, + ClientName: clientName, + BaseDomain: baseDomain, + RegistryDomains: registryDomains, + RepoURL: repoURL, + RepoBranch: repoBranch, + }, nil +} + +// extractFromAppviewTemplate renders the appview config template with +// zero-value ConfigValues and parses the resulting YAML to extract +// deployment-specific values. The template is the single source of truth. +func extractFromAppviewTemplate() (clientName, baseDomain string, registryDomains []string, err error) { + rendered, err := renderConfig(appviewConfigTmpl, &ConfigValues{}) + if err != nil { + return "", "", nil, fmt.Errorf("render appview template: %w", err) + } + + var cfg struct { + Server struct { + BaseURL string `yaml:"base_url"` + ClientName string `yaml:"client_name"` + RegistryDomains []string `yaml:"registry_domains"` + } `yaml:"server"` + } + if err := yaml.Unmarshal([]byte(rendered), &cfg); err != nil { + return "", "", nil, fmt.Errorf("parse appview template YAML: %w", err) + } + + clientName = strings.ToLower(cfg.Server.ClientName) + baseDomain = strings.TrimPrefix(cfg.Server.BaseURL, "https://") + registryDomains = cfg.Server.RegistryDomains + + return clientName, baseDomain, registryDomains, nil +} + +// readSSHPublicKey reads an SSH public key from a file path. +func readSSHPublicKey(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("--ssh-key is required (path to SSH public key file)") + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read SSH public key %s: %w", path, err) + } + key := strings.TrimSpace(string(data)) + if key == "" { + return "", fmt.Errorf("SSH public key file %s is empty", path) + } + return key, nil +} + +// resolveInteractive fills in any empty Zone/Plan fields by launching +// interactive TUI pickers that query the UpCloud API. +func resolveInteractive(ctx context.Context, svc *service.Service, cfg *InfraConfig) error { + if cfg.Zone == "" { + z, err := pickZone(ctx, svc) + if err != nil { + return fmt.Errorf("zone picker: %w", err) + } + cfg.Zone = z + } + if cfg.Plan == "" { + p, err := pickPlan(ctx, svc) + if err != nil { + return fmt.Errorf("plan picker: %w", err) + } + cfg.Plan = p + } + return nil +} + +// newService creates an UpCloud API client. If token is non-empty it's used +// directly; otherwise credentials are read from UPCLOUD_TOKEN env var. +func newService(token string) (*service.Service, error) { + var c *client.Client + var err error + if token != "" { + c = client.New("", "", client.WithBearerAuth(token), client.WithTimeout(120*time.Second)) + } else { + c, err = client.NewFromEnv(client.WithTimeout(120 * time.Second)) + if err != nil { + return nil, fmt.Errorf("create UpCloud client: %w\n\nPass --token or set UPCLOUD_TOKEN", err) + } + } + return service.New(c), nil +} diff --git a/deploy/upcloud/configs/appview.yaml.tmpl b/deploy/upcloud/configs/appview.yaml.tmpl new file mode 100644 index 0000000..2d04ac6 --- /dev/null +++ b/deploy/upcloud/configs/appview.yaml.tmpl @@ -0,0 +1,25 @@ +version: "0.1" +log_level: info +server: + addr: :5000 + base_url: "https://seamark.dev" + default_hold_did: "{{.HoldDid}}" + oauth_key_path: "{{.BasePath}}/oauth/client.key" + client_name: Seamark + client_short_name: Seamark + registry_domains: + - "buoy.cr" + - "bouy.cr" +ui: + database_path: "{{.BasePath}}/ui.db" + theme: seamark +jetstream: + url: wss://jetstream2.us-west.bsky.network/subscribe + backfill_enabled: true + relay_endpoint: https://relay1.us-east.bsky.network +auth: + key_path: "{{.BasePath}}/auth/private-key.pem" + cert_path: "{{.BasePath}}/auth/private-key.crt" +legal: + company_name: Seamark + jurisdiction: State of Texas, United States diff --git a/deploy/upcloud/configs/cloudinit.sh.tmpl b/deploy/upcloud/configs/cloudinit.sh.tmpl new file mode 100644 index 0000000..f21ace2 --- /dev/null +++ b/deploy/upcloud/configs/cloudinit.sh.tmpl @@ -0,0 +1,72 @@ +#!/bin/bash +set -euo pipefail +exec > >(tee {{.LogFile}}) 2>&1 + +echo "=== {{.DisplayName}} Setup: {{.BinaryName}} ===" +echo "Started at $(date -u)" + +# Wait for DNS resolution +echo "Waiting for DNS..." +for i in $(seq 1 30); do + if host go.dev >/dev/null 2>&1; then + echo "DNS ready after ${i}s" + break + fi + sleep 1 +done + +# System packages +export DEBIAN_FRONTEND=noninteractive +apt-get update && apt-get upgrade -y +apt-get install -y git gcc make curl libsqlite3-dev nodejs npm htop + +# Swap (for builds on small instances) +if [ ! -f /swapfile ]; then + dd if=/dev/zero of=/swapfile bs=1M count=2048 + chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile + echo '/swapfile none swap sw 0 0' >> /etc/fstab +fi + +# Go {{.GoVersion}} +curl -fsSL https://go.dev/dl/go{{.GoVersion}}.linux-amd64.tar.gz | tar -C /usr/local -xz +echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh +export PATH=$PATH:/usr/local/go/bin +export GOTMPDIR=/var/tmp + +# Clone & build +if [ -d {{.InstallDir}} ]; then + cd {{.InstallDir}} && git pull origin {{.RepoBranch}} +else + git clone -b {{.RepoBranch}} {{.RepoURL}} {{.InstallDir}} + cd {{.InstallDir}} +fi +npm ci +go generate ./... +CGO_ENABLED=1 go build \ + -ldflags="-s -w -linkmode external -extldflags '-static'" \ + -tags sqlite_omit_load_extension -trimpath \ + -o bin/{{.BinaryName}} ./cmd/{{.BuildCmd}} + +# Service user & data dirs +useradd --system --no-create-home --shell /usr/sbin/nologin {{.SystemUser}} || true +mkdir -p {{.DataDir}} && chown {{.SystemUser}}:{{.SystemUser}} {{.DataDir}} + +# Config file +mkdir -p {{.ConfigDir}} +if [ ! -f {{.ConfigPath}} ]; then +cat > {{.ConfigPath}} << 'CFGEOF' +{{.ConfigYAML}} +CFGEOF +else + echo "Config {{.ConfigPath}} already exists, skipping" +fi + +# Systemd service +cat > /etc/systemd/system/{{.ServiceName}}.service << 'SVCEOF' +{{.ServiceUnit}} +SVCEOF +systemctl daemon-reload +systemctl enable {{.ServiceName}} + +echo "=== Setup complete at $(date -u) ===" +echo "Edit {{.ConfigPath}} then: systemctl start {{.ServiceName}}" diff --git a/deploy/upcloud/configs/hold.yaml.tmpl b/deploy/upcloud/configs/hold.yaml.tmpl new file mode 100644 index 0000000..a28e26f --- /dev/null +++ b/deploy/upcloud/configs/hold.yaml.tmpl @@ -0,0 +1,30 @@ +version: "0.1" +log_level: info +storage: + access_key: "{{.S3AccessKey}}" + secret_key: "{{.S3SecretKey}}" + region: "{{.S3Region}}" + bucket: "{{.S3Bucket}}" + endpoint: "{{.S3Endpoint}}" +server: + addr: :8080 + public_url: "https://{{.HoldDomain}}" + public: false +registration: + owner_did: "did:plc:pddp4xt5lgnv2qsegbzzs4xg" + allow_all_crew: true + enable_bluesky_posts: false +database: + path: "{{.BasePath}}" +admin: + enabled: true +quota: + tiers: + deckhand: + quota: 5GB + bosun: + quota: 50GB + quartermaster: + quota: 100GB + defaults: + new_crew_tier: deckhand diff --git a/deploy/upcloud/go.mod b/deploy/upcloud/go.mod new file mode 100644 index 0000000..d668431 --- /dev/null +++ b/deploy/upcloud/go.mod @@ -0,0 +1,45 @@ +module atcr.io/deploy + +go 1.25.4 + +require ( + github.com/UpCloudLtd/upcloud-go-api/v8 v8.34.3 + github.com/charmbracelet/huh v0.8.0 + github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v3 v3.0.4 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/deploy/upcloud/go.sum b/deploy/upcloud/go.sum new file mode 100644 index 0000000..0b56f37 --- /dev/null +++ b/deploy/upcloud/go.sum @@ -0,0 +1,109 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/UpCloudLtd/upcloud-go-api/v8 v8.34.3 h1:7ba03u4L5LafZPVO2k6B0/f114k5dFF3GtAN7FEKfno= +github.com/UpCloudLtd/upcloud-go-api/v8 v8.34.3/go.mod h1:NBh1d/ip1bhdAIhuPWbyPme7tbLzDTV7dhutUmU1vg8= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/deploy/upcloud/goversion.go b/deploy/upcloud/goversion.go new file mode 100644 index 0000000..1f04906 --- /dev/null +++ b/deploy/upcloud/goversion.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +// requiredGoVersion reads the Go version from the root go.mod file. +// Returns a version string like "1.25.4" for use in download URLs. +func requiredGoVersion() (string, error) { + _, thisFile, _, _ := runtime.Caller(0) + rootMod := filepath.Join(filepath.Dir(thisFile), "..", "..", "go.mod") + + data, err := os.ReadFile(rootMod) + if err != nil { + return "", fmt.Errorf("read root go.mod: %w", err) + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "go ") { + version := strings.TrimPrefix(line, "go ") + version = strings.TrimSpace(version) + // Validate it looks like a version + if len(version) > 0 && version[0] >= '1' && version[0] <= '9' { + return version, nil + } + } + } + + return "", fmt.Errorf("no 'go X.Y.Z' directive found in %s", rootMod) +} diff --git a/deploy/upcloud/main.go b/deploy/upcloud/main.go new file mode 100644 index 0000000..a6415d9 --- /dev/null +++ b/deploy/upcloud/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "upcloud", + Short: "ATCR infrastructure provisioning tool for UpCloud", + SilenceUsage: true, +} + +func init() { + rootCmd.PersistentFlags().StringP("token", "t", "", "UpCloud API token (env: UPCLOUD_TOKEN)") +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/deploy/upcloud/naming.go b/deploy/upcloud/naming.go new file mode 100644 index 0000000..4246803 --- /dev/null +++ b/deploy/upcloud/naming.go @@ -0,0 +1,52 @@ +package main + +import "strings" + +// Naming derives all infrastructure names and paths from a single ClientName. +type Naming struct { + ClientName string // e.g. "seamark" +} + +// DisplayName returns the title-cased client name (e.g. "Seamark"). +func (n Naming) DisplayName() string { + if n.ClientName == "" { + return "" + } + return strings.ToUpper(n.ClientName[:1]) + n.ClientName[1:] +} + +// SystemUser returns the unix user name. +func (n Naming) SystemUser() string { return n.ClientName } + +// InstallDir returns the source/build directory (e.g. "/opt/seamark"). +func (n Naming) InstallDir() string { return "/opt/" + n.ClientName } + +// ConfigDir returns the config directory (e.g. "/etc/seamark"). +func (n Naming) ConfigDir() string { return "/etc/" + n.ClientName } + +// BasePath returns the data directory (e.g. "/var/lib/seamark"). +func (n Naming) BasePath() string { return "/var/lib/" + n.ClientName } + +// LogFile returns the setup log path (e.g. "/var/log/seamark-setup.log"). +func (n Naming) LogFile() string { return "/var/log/" + n.ClientName + "-setup.log" } + +// Appview returns the appview binary/service/server name (e.g. "seamark-appview"). +func (n Naming) Appview() string { return n.ClientName + "-appview" } + +// Hold returns the hold binary/service/server name (e.g. "seamark-hold"). +func (n Naming) Hold() string { return n.ClientName + "-hold" } + +// AppviewConfigPath returns the appview config file path. +func (n Naming) AppviewConfigPath() string { return n.ConfigDir() + "/appview.yaml" } + +// HoldConfigPath returns the hold config file path. +func (n Naming) HoldConfigPath() string { return n.ConfigDir() + "/hold.yaml" } + +// NetworkName returns the private network name (e.g. "seamark-private"). +func (n Naming) NetworkName() string { return n.ClientName + "-private" } + +// LBName returns the load balancer name (e.g. "seamark-lb"). +func (n Naming) LBName() string { return n.ClientName + "-lb" } + +// S3Name returns the name used for S3 storage, user, and bucket. +func (n Naming) S3Name() string { return n.ClientName } diff --git a/deploy/upcloud/picker.go b/deploy/upcloud/picker.go new file mode 100644 index 0000000..f4a17a6 --- /dev/null +++ b/deploy/upcloud/picker.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "fmt" + "sort" + + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/service" + "github.com/charmbracelet/huh" +) + +// pickZone fetches available zones from the UpCloud API and presents an +// interactive selector. Only public zones are shown. +func pickZone(ctx context.Context, svc *service.Service) (string, error) { + resp, err := svc.GetZones(ctx) + if err != nil { + return "", fmt.Errorf("fetch zones: %w", err) + } + + var opts []huh.Option[string] + for _, z := range resp.Zones { + if z.Public != upcloud.True { + continue + } + label := fmt.Sprintf("%s — %s", z.ID, z.Description) + opts = append(opts, huh.NewOption(label, z.ID)) + } + + if len(opts) == 0 { + return "", fmt.Errorf("no public zones available") + } + + sort.Slice(opts, func(i, j int) bool { + return opts[i].Value < opts[j].Value + }) + + var zone string + err = huh.NewSelect[string](). + Title("Select a zone"). + Options(opts...). + Value(&zone). + Run() + if err != nil { + return "", err + } + + return zone, nil +} + +// pickPlan fetches available plans from the UpCloud API and presents an +// interactive selector. GPU plans are filtered out. +func pickPlan(ctx context.Context, svc *service.Service) (string, error) { + resp, err := svc.GetPlans(ctx) + if err != nil { + return "", fmt.Errorf("fetch plans: %w", err) + } + + var opts []huh.Option[string] + for _, p := range resp.Plans { + if p.GPUAmount > 0 { + continue + } + memGB := p.MemoryAmount / 1024 + label := fmt.Sprintf("%s — %d CPU, %d GB RAM, %d GB disk", p.Name, p.CoreNumber, memGB, p.StorageSize) + opts = append(opts, huh.NewOption(label, p.Name)) + } + + if len(opts) == 0 { + return "", fmt.Errorf("no plans available") + } + + sort.Slice(opts, func(i, j int) bool { + return opts[i].Value < opts[j].Value + }) + + var plan string + err = huh.NewSelect[string](). + Title("Select a plan"). + Options(opts...). + Value(&plan). + Run() + if err != nil { + return "", err + } + + return plan, nil +} diff --git a/deploy/upcloud/provision.go b/deploy/upcloud/provision.go new file mode 100644 index 0000000..83d54c0 --- /dev/null +++ b/deploy/upcloud/provision.go @@ -0,0 +1,856 @@ +package main + +import ( + "bufio" + "context" + "crypto/sha256" + "fmt" + "os" + "strings" + "time" + + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/service" + "github.com/spf13/cobra" +) + +var provisionCmd = &cobra.Command{ + Use: "provision", + Short: "Create all infrastructure (servers, network, LB, firewall)", + RunE: func(cmd *cobra.Command, args []string) error { + token, _ := cmd.Root().PersistentFlags().GetString("token") + zone, _ := cmd.Flags().GetString("zone") + plan, _ := cmd.Flags().GetString("plan") + sshKey, _ := cmd.Flags().GetString("ssh-key") + s3Secret, _ := cmd.Flags().GetString("s3-secret") + return cmdProvision(token, zone, plan, sshKey, s3Secret) + }, +} + +func init() { + provisionCmd.Flags().String("zone", "", "UpCloud zone (interactive picker if omitted)") + provisionCmd.Flags().String("plan", "", "Server plan (interactive picker if omitted)") + provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)") + provisionCmd.Flags().String("s3-secret", "", "S3 secret access key (for existing object storage)") + provisionCmd.MarkFlagRequired("ssh-key") + rootCmd.AddCommand(provisionCmd) +} + +func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { + cfg, err := loadConfig(zone, plan, sshKeyPath, s3Secret) + if err != nil { + return err + } + + naming := cfg.Naming() + + svc, err := newService(token) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // Load existing state or start fresh + state, err := loadState() + if err != nil { + state = &InfraState{} + } + + // Use zone from state if not provided via flags + if cfg.Zone == "" && state.Zone != "" { + cfg.Zone = state.Zone + } + + // Only need interactive picker if we still need to create resources + needsServers := state.Appview.UUID == "" || state.Hold.UUID == "" + if cfg.Zone == "" || (needsServers && cfg.Plan == "") { + if err := resolveInteractive(ctx, svc, cfg); err != nil { + return err + } + } + + if state.Zone == "" { + state.Zone = cfg.Zone + } + state.ClientName = cfg.ClientName + state.RepoBranch = cfg.RepoBranch + + goVersion, err := requiredGoVersion() + if err != nil { + return err + } + + fmt.Printf("Provisioning %s infrastructure in zone %s...\n", naming.DisplayName(), cfg.Zone) + fmt.Printf("Go version: %s (from go.mod)\n", goVersion) + if needsServers { + fmt.Printf("Server plan: %s\n", cfg.Plan) + } + fmt.Println() + + // S3 secret key — from flag for existing storage, from API for new + s3SecretKey := cfg.S3SecretKey + + // 1. Object storage + if state.ObjectStorage.UUID != "" { + fmt.Printf("Object storage: %s (exists)\n", state.ObjectStorage.UUID) + // Refresh discoverable fields if missing (e.g. pre-seeded UUID only) + if state.ObjectStorage.Endpoint == "" || state.ObjectStorage.Bucket == "" { + fmt.Println(" Discovering endpoint, bucket, access key...") + discovered, err := lookupObjectStorage(ctx, svc, state.ObjectStorage.UUID) + if err != nil { + return err + } + state.ObjectStorage.Endpoint = discovered.Endpoint + state.ObjectStorage.Region = discovered.Region + if discovered.Bucket != "" { + state.ObjectStorage.Bucket = discovered.Bucket + } + if discovered.AccessKeyID != "" { + state.ObjectStorage.AccessKeyID = discovered.AccessKeyID + } + saveState(state) + } + } else { + fmt.Println("Creating object storage...") + objState, secretKey, err := provisionObjectStorage(ctx, svc, cfg.Zone, naming.S3Name()) + if err != nil { + return fmt.Errorf("object storage: %w", err) + } + state.ObjectStorage = objState + s3SecretKey = secretKey + saveState(state) + fmt.Printf(" S3 Secret Key: %s\n", secretKey) + } + + fmt.Printf(" Endpoint: %s\n", state.ObjectStorage.Endpoint) + fmt.Printf(" Region: %s\n", state.ObjectStorage.Region) + fmt.Printf(" Bucket: %s\n", state.ObjectStorage.Bucket) + fmt.Printf(" Access Key: %s\n\n", state.ObjectStorage.AccessKeyID) + + // Hold domain is zone-based (e.g. us-chi1.cove.seamark.dev) + holdDomain := cfg.Zone + ".cove." + cfg.BaseDomain + + // Build config template values + vals := &ConfigValues{ + S3Endpoint: state.ObjectStorage.Endpoint, + S3Region: state.ObjectStorage.Region, + S3Bucket: state.ObjectStorage.Bucket, + S3AccessKey: state.ObjectStorage.AccessKeyID, + S3SecretKey: s3SecretKey, + Zone: cfg.Zone, + HoldDomain: holdDomain, + HoldDid: "did:web:" + holdDomain, + BasePath: naming.BasePath(), + } + + // 2. Private network + if state.Network.UUID != "" { + fmt.Printf("Network: %s (exists)\n", state.Network.UUID) + } else { + fmt.Println("Creating private network...") + network, err := svc.CreateNetwork(ctx, &request.CreateNetworkRequest{ + Name: naming.NetworkName(), + Zone: cfg.Zone, + IPNetworks: upcloud.IPNetworkSlice{ + { + Address: privateNetworkCIDR, + DHCP: upcloud.True, + DHCPDefaultRoute: upcloud.False, + DHCPDns: []string{"8.8.8.8", "1.1.1.1"}, + Family: upcloud.IPAddressFamilyIPv4, + Gateway: "", + }, + }, + }) + if err != nil { + return fmt.Errorf("create network: %w", err) + } + state.Network = StateRef{UUID: network.UUID} + saveState(state) + fmt.Printf(" Network: %s (%s)\n", network.UUID, privateNetworkCIDR) + } + + // Find Debian template (needed for server creation) + templateUUID, err := findDebianTemplate(ctx, svc) + if err != nil { + return err + } + + // 3. Appview server + if state.Appview.UUID != "" { + fmt.Printf("Appview: %s (exists)\n", state.Appview.UUID) + appviewScript, err := generateAppviewCloudInit(cfg, vals, goVersion) + if err != nil { + return err + } + if err := syncCloudInit("appview", state.Appview.PublicIP, appviewScript); err != nil { + return err + } + } else { + fmt.Println("Creating appview server...") + appviewUserData, err := generateAppviewCloudInit(cfg, vals, goVersion) + if err != nil { + return err + } + appview, err := createServer(ctx, svc, cfg, templateUUID, state.Network.UUID, naming.Appview(), appviewUserData) + if err != nil { + return fmt.Errorf("create appview: %w", err) + } + state.Appview = *appview + saveState(state) + fmt.Printf(" Appview: %s (public: %s, private: %s)\n", appview.UUID, appview.PublicIP, appview.PrivateIP) + } + + // 4. Hold server + if state.Hold.UUID != "" { + fmt.Printf("Hold: %s (exists)\n", state.Hold.UUID) + holdScript, err := generateHoldCloudInit(cfg, vals, goVersion) + if err != nil { + return err + } + if err := syncCloudInit("hold", state.Hold.PublicIP, holdScript); err != nil { + return err + } + } else { + fmt.Println("Creating hold server...") + holdUserData, err := generateHoldCloudInit(cfg, vals, goVersion) + if err != nil { + return err + } + hold, err := createServer(ctx, svc, cfg, templateUUID, state.Network.UUID, naming.Hold(), holdUserData) + if err != nil { + return fmt.Errorf("create hold: %w", err) + } + state.Hold = *hold + saveState(state) + fmt.Printf(" Hold: %s (public: %s, private: %s)\n", hold.UUID, hold.PublicIP, hold.PrivateIP) + } + + // 5. Firewall rules (idempotent — replaces all rules) + fmt.Println("Configuring firewall rules...") + for _, s := range []struct { + name string + uuid string + }{ + {"appview", state.Appview.UUID}, + {"hold", state.Hold.UUID}, + } { + if err := createFirewallRules(ctx, svc, s.uuid, privateNetworkCIDR); err != nil { + return fmt.Errorf("firewall %s: %w", s.name, err) + } + } + + // 6. Load balancer + if state.LB.UUID != "" { + fmt.Printf("Load balancer: %s (exists)\n", state.LB.UUID) + } else { + fmt.Println("Creating load balancer (Essentials tier)...") + lb, err := createLoadBalancer(ctx, svc, cfg, naming, state.Network.UUID, state.Appview.PrivateIP, state.Hold.PrivateIP, holdDomain) + if err != nil { + return fmt.Errorf("create LB: %w", err) + } + state.LB = StateRef{UUID: lb.UUID} + saveState(state) + } + + // Always reconcile TLS certs (handles partial failures and re-runs) + tlsDomains := []string{cfg.BaseDomain} + tlsDomains = append(tlsDomains, cfg.RegistryDomains...) + tlsDomains = append(tlsDomains, holdDomain) + if err := ensureLBCertificates(ctx, svc, state.LB.UUID, tlsDomains); err != nil { + return fmt.Errorf("LB certificates: %w", err) + } + + // Fetch LB DNS name for output + lbDNS := "" + if state.LB.UUID != "" { + lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{UUID: state.LB.UUID}) + if err == nil { + for _, n := range lb.Networks { + if n.Type == upcloud.LoadBalancerNetworkTypePublic { + lbDNS = n.DNSName + } + } + } + } + + fmt.Println("\n=== Provisioning Complete ===") + fmt.Println() + fmt.Println("DNS records needed:") + if lbDNS != "" { + fmt.Printf(" CNAME %-24s → %s\n", cfg.BaseDomain, lbDNS) + for _, rd := range cfg.RegistryDomains { + fmt.Printf(" CNAME %-24s → %s\n", rd, lbDNS) + } + fmt.Printf(" CNAME %-24s → %s\n", holdDomain, lbDNS) + } else { + fmt.Println(" (LB DNS name not yet available — check 'status' in a few minutes)") + } + fmt.Println() + fmt.Println("SSH access:") + fmt.Printf(" ssh root@%s # appview\n", state.Appview.PublicIP) + fmt.Printf(" ssh root@%s # hold\n", state.Hold.PublicIP) + fmt.Println() + fmt.Println("Next steps:") + fmt.Println(" 1. Wait ~5 min for cloud-init to complete") + fmt.Printf(" 2. systemctl start %s / %s\n", naming.Appview(), naming.Hold()) + fmt.Println(" 3. Configure DNS records above") + + return nil +} + +// provisionObjectStorage creates a new Managed Object Storage with a user, access key, and bucket. +// Returns the state and the secret key separately (only available at creation time). +func provisionObjectStorage(ctx context.Context, svc *service.Service, zone, s3Name string) (ObjectStorageState, string, error) { + // Map compute zone to object storage region (e.g. us-chi1 → us-east-1) + region := objectStorageRegion(zone) + + storage, err := svc.CreateManagedObjectStorage(ctx, &request.CreateManagedObjectStorageRequest{ + Name: s3Name, + Region: region, + ConfiguredStatus: upcloud.ManagedObjectStorageConfiguredStatusStarted, + Networks: []upcloud.ManagedObjectStorageNetwork{ + { + Family: upcloud.IPAddressFamilyIPv4, + Name: "public", + Type: "public", + }, + }, + }) + if err != nil { + return ObjectStorageState{}, "", fmt.Errorf("create storage: %w", err) + } + fmt.Printf(" Created: %s (region: %s)\n", storage.UUID, region) + + // Find endpoint + var endpoint string + for _, ep := range storage.Endpoints { + if ep.DomainName != "" { + endpoint = "https://" + ep.DomainName + break + } + } + + // Create user + _, err = svc.CreateManagedObjectStorageUser(ctx, &request.CreateManagedObjectStorageUserRequest{ + ServiceUUID: storage.UUID, + Username: s3Name, + }) + if err != nil { + return ObjectStorageState{}, "", fmt.Errorf("create user: %w", err) + } + + // Attach admin policy + err = svc.AttachManagedObjectStorageUserPolicy(ctx, &request.AttachManagedObjectStorageUserPolicyRequest{ + ServiceUUID: storage.UUID, + Username: s3Name, + Name: "admin", + }) + if err != nil { + return ObjectStorageState{}, "", fmt.Errorf("attach policy: %w", err) + } + + // Create access key (secret is only returned here) + accessKey, err := svc.CreateManagedObjectStorageUserAccessKey(ctx, &request.CreateManagedObjectStorageUserAccessKeyRequest{ + ServiceUUID: storage.UUID, + Username: s3Name, + }) + if err != nil { + return ObjectStorageState{}, "", fmt.Errorf("create access key: %w", err) + } + + secretKey := "" + if accessKey.SecretAccessKey != nil { + secretKey = *accessKey.SecretAccessKey + } + + // Create bucket + _, err = svc.CreateManagedObjectStorageBucket(ctx, &request.CreateManagedObjectStorageBucketRequest{ + ServiceUUID: storage.UUID, + Name: s3Name, + }) + if err != nil { + return ObjectStorageState{}, "", fmt.Errorf("create bucket: %w", err) + } + + return ObjectStorageState{ + UUID: storage.UUID, + Endpoint: endpoint, + Region: region, + Bucket: s3Name, + AccessKeyID: accessKey.AccessKeyID, + }, secretKey, nil +} + +// objectStorageRegion maps a compute zone to the nearest object storage region. +func objectStorageRegion(zone string) string { + switch { + case strings.HasPrefix(zone, "us-"): + return "us-east-1" + case strings.HasPrefix(zone, "de-"): + return "europe-1" + case strings.HasPrefix(zone, "fi-"): + return "europe-1" + case strings.HasPrefix(zone, "nl-"): + return "europe-1" + case strings.HasPrefix(zone, "es-"): + return "europe-1" + case strings.HasPrefix(zone, "pl-"): + return "europe-1" + case strings.HasPrefix(zone, "se-"): + return "europe-1" + case strings.HasPrefix(zone, "au-"): + return "australia-1" + case strings.HasPrefix(zone, "sg-"): + return "singapore-1" + default: + return "us-east-1" + } +} + +func createServer(ctx context.Context, svc *service.Service, cfg *InfraConfig, templateUUID, networkUUID, title, userData string) (*ServerState, error) { + storageTier := "maxiops" + if strings.HasPrefix(strings.ToUpper(cfg.Plan), "DEV-") { + storageTier = "standard" + } + + // Look up the plan's storage size from the API instead of hardcoding. + diskSize := 25 // fallback + plans, err := svc.GetPlans(ctx) + if err == nil { + for _, p := range plans.Plans { + if p.Name == cfg.Plan { + diskSize = p.StorageSize + break + } + } + } + + details, err := svc.CreateServer(ctx, &request.CreateServerRequest{ + Zone: cfg.Zone, + Title: title, + Hostname: title, + Plan: cfg.Plan, + Metadata: upcloud.True, + UserData: userData, + Firewall: "on", + PasswordDelivery: "none", + StorageDevices: request.CreateServerStorageDeviceSlice{ + { + Action: "clone", + Storage: templateUUID, + Title: title + "-disk", + Size: diskSize, + Tier: storageTier, + }, + }, + Networking: &request.CreateServerNetworking{ + Interfaces: request.CreateServerInterfaceSlice{ + { + Index: 1, + Type: upcloud.IPAddressAccessPublic, + IPAddresses: request.CreateServerIPAddressSlice{ + {Family: upcloud.IPAddressFamilyIPv4}, + }, + }, + { + Index: 2, + Type: upcloud.IPAddressAccessPrivate, + Network: networkUUID, + IPAddresses: request.CreateServerIPAddressSlice{ + {Family: upcloud.IPAddressFamilyIPv4}, + }, + }, + }, + }, + LoginUser: &request.LoginUser{ + CreatePassword: "no", + SSHKeys: request.SSHKeySlice{cfg.SSHPublicKey}, + }, + }) + if err != nil { + return nil, err + } + + fmt.Printf(" Waiting for server %s to start...\n", details.UUID) + details, err = svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{ + UUID: details.UUID, + DesiredState: upcloud.ServerStateStarted, + }) + if err != nil { + return nil, fmt.Errorf("wait for server: %w", err) + } + + s := &ServerState{UUID: details.UUID} + for _, iface := range details.Networking.Interfaces { + for _, addr := range iface.IPAddresses { + if addr.Family == upcloud.IPAddressFamilyIPv4 { + switch iface.Type { + case upcloud.IPAddressAccessPublic: + s.PublicIP = addr.Address + case upcloud.IPAddressAccessPrivate: + s.PrivateIP = addr.Address + } + } + } + } + + return s, nil +} + +func createFirewallRules(ctx context.Context, svc *service.Service, serverUUID, privateCIDR string) error { + networkBase := strings.TrimSuffix(privateCIDR, "/24") + networkBase = strings.TrimSuffix(networkBase, ".0") + + return svc.CreateFirewallRules(ctx, &request.CreateFirewallRulesRequest{ + ServerUUID: serverUUID, + FirewallRules: request.FirewallRuleSlice{ + { + Direction: upcloud.FirewallRuleDirectionIn, + Action: upcloud.FirewallRuleActionAccept, + Family: upcloud.IPAddressFamilyIPv4, + Protocol: upcloud.FirewallRuleProtocolTCP, + DestinationPortStart: "22", + DestinationPortEnd: "22", + Position: 1, + Comment: "Allow SSH", + }, + { + Direction: upcloud.FirewallRuleDirectionIn, + Action: upcloud.FirewallRuleActionAccept, + Family: upcloud.IPAddressFamilyIPv4, + SourceAddressStart: networkBase + ".0", + SourceAddressEnd: networkBase + ".255", + Position: 2, + Comment: "Allow private network", + }, + { + Direction: upcloud.FirewallRuleDirectionIn, + Action: upcloud.FirewallRuleActionDrop, + Position: 3, + Comment: "Drop all other inbound", + }, + }, + }) +} + +func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain string) (*upcloud.LoadBalancer, error) { + lb, err := svc.CreateLoadBalancer(ctx, &request.CreateLoadBalancerRequest{ + Name: naming.LBName(), + Plan: "essentials", + Zone: cfg.Zone, + ConfiguredStatus: upcloud.LoadBalancerConfiguredStatusStarted, + Networks: []request.LoadBalancerNetwork{ + { + Name: "public", + Type: upcloud.LoadBalancerNetworkTypePublic, + Family: upcloud.LoadBalancerAddressFamilyIPv4, + }, + { + Name: "private", + Type: upcloud.LoadBalancerNetworkTypePrivate, + Family: upcloud.LoadBalancerAddressFamilyIPv4, + UUID: networkUUID, + }, + }, + Frontends: []request.LoadBalancerFrontend{ + { + Name: "https", + Mode: upcloud.LoadBalancerModeHTTP, + Port: 443, + DefaultBackend: "appview", + Networks: []upcloud.LoadBalancerFrontendNetwork{ + {Name: "public"}, + }, + Rules: []request.LoadBalancerFrontendRule{ + { + Name: "route-hold", + Priority: 10, + Matchers: []upcloud.LoadBalancerMatcher{ + { + Type: upcloud.LoadBalancerMatcherTypeHost, + Host: &upcloud.LoadBalancerMatcherHost{ + Value: holdDomain, + }, + }, + }, + Actions: []upcloud.LoadBalancerAction{ + { + Type: upcloud.LoadBalancerActionTypeUseBackend, + UseBackend: &upcloud.LoadBalancerActionUseBackend{ + Backend: "hold", + }, + }, + }, + }, + }, + }, + { + Name: "http-redirect", + Mode: upcloud.LoadBalancerModeHTTP, + Port: 80, + DefaultBackend: "appview", + Networks: []upcloud.LoadBalancerFrontendNetwork{ + {Name: "public"}, + }, + Rules: []request.LoadBalancerFrontendRule{ + { + Name: "redirect-https", + Priority: 10, + Matchers: []upcloud.LoadBalancerMatcher{ + { + Type: upcloud.LoadBalancerMatcherTypeSrcPort, + SrcPort: &upcloud.LoadBalancerMatcherInteger{ + Method: upcloud.LoadBalancerIntegerMatcherMethodEqual, + Value: 80, + }, + }, + }, + Actions: []upcloud.LoadBalancerAction{ + { + Type: upcloud.LoadBalancerActionTypeHTTPRedirect, + HTTPRedirect: &upcloud.LoadBalancerActionHTTPRedirect{ + Scheme: upcloud.LoadBalancerActionHTTPRedirectSchemeHTTPS, + }, + }, + }, + }, + }, + }, + }, + Resolvers: []request.LoadBalancerResolver{}, + Backends: []request.LoadBalancerBackend{ + { + Name: "appview", + Members: []request.LoadBalancerBackendMember{ + { + Name: "appview-1", + Type: upcloud.LoadBalancerBackendMemberTypeStatic, + IP: appviewIP, + Port: 5000, + Weight: 100, + MaxSessions: 1000, + Enabled: true, + }, + }, + Properties: &upcloud.LoadBalancerBackendProperties{ + HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, + HealthCheckURL: "/health", + }, + }, + { + Name: "hold", + Members: []request.LoadBalancerBackendMember{ + { + Name: "hold-1", + Type: upcloud.LoadBalancerBackendMemberTypeStatic, + IP: holdIP, + Port: 8080, + Weight: 100, + MaxSessions: 1000, + Enabled: true, + }, + }, + Properties: &upcloud.LoadBalancerBackendProperties{ + HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, + HealthCheckURL: "/xrpc/_health", + }, + }, + }, + }) + if err != nil { + return nil, err + } + + return lb, nil +} + +// ensureLBCertificates reconciles TLS certificate bundles on the load balancer. +// It skips domains that already have a TLS config attached and creates missing ones. +func ensureLBCertificates(ctx context.Context, svc *service.Service, lbUUID string, tlsDomains []string) error { + lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{UUID: lbUUID}) + if err != nil { + return fmt.Errorf("get load balancer: %w", err) + } + + // Build set of existing TLS config names on the "https" frontend + existing := make(map[string]bool) + for _, fe := range lb.Frontends { + if fe.Name == "https" { + for _, tc := range fe.TLSConfigs { + existing[tc.Name] = true + } + } + } + + for _, domain := range tlsDomains { + certName := "tls-" + strings.ReplaceAll(domain, ".", "-") + if existing[certName] { + fmt.Printf(" TLS certificate: %s (exists)\n", domain) + continue + } + + bundle, err := svc.CreateLoadBalancerCertificateBundle(ctx, &request.CreateLoadBalancerCertificateBundleRequest{ + Type: upcloud.LoadBalancerCertificateBundleTypeDynamic, + Name: certName, + KeyType: "ecdsa", + Hostnames: []string{domain}, + }) + if err != nil { + return fmt.Errorf("create TLS cert for %s: %w", domain, err) + } + + _, err = svc.CreateLoadBalancerFrontendTLSConfig(ctx, &request.CreateLoadBalancerFrontendTLSConfigRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + Config: request.LoadBalancerFrontendTLSConfig{ + Name: certName, + CertificateBundleUUID: bundle.UUID, + }, + }) + if err != nil { + return fmt.Errorf("attach TLS cert %s to frontend: %w", domain, err) + } + fmt.Printf(" TLS certificate: %s\n", domain) + } + + return nil +} + +// lookupObjectStorage discovers details of an existing Managed Object Storage. +func lookupObjectStorage(ctx context.Context, svc *service.Service, uuid string) (ObjectStorageState, error) { + storage, err := svc.GetManagedObjectStorage(ctx, &request.GetManagedObjectStorageRequest{ + UUID: uuid, + }) + if err != nil { + return ObjectStorageState{}, fmt.Errorf("get object storage %s: %w", uuid, err) + } + + var endpoint string + for _, ep := range storage.Endpoints { + if ep.DomainName != "" { + endpoint = "https://" + ep.DomainName + break + } + } + + var bucket string + buckets, err := svc.GetManagedObjectStorageBucketMetrics(ctx, &request.GetManagedObjectStorageBucketMetricsRequest{ + ServiceUUID: uuid, + }) + if err == nil { + for _, b := range buckets { + if !b.Deleted { + bucket = b.Name + break + } + } + } + + var accessKeyID string + users, err := svc.GetManagedObjectStorageUsers(ctx, &request.GetManagedObjectStorageUsersRequest{ + ServiceUUID: uuid, + }) + if err == nil { + for _, u := range users { + for _, k := range u.AccessKeys { + if k.Status == "Active" { + accessKeyID = k.AccessKeyID + break + } + } + if accessKeyID != "" { + break + } + } + } + + return ObjectStorageState{ + UUID: uuid, + Endpoint: endpoint, + Region: storage.Region, + Bucket: bucket, + AccessKeyID: accessKeyID, + }, nil +} + +func findDebianTemplate(ctx context.Context, svc *service.Service) (string, error) { + storages, err := svc.GetStorages(ctx, &request.GetStoragesRequest{ + Type: "template", + }) + if err != nil { + return "", fmt.Errorf("list templates: %w", err) + } + + var debian13, debian12 string + for _, s := range storages.Storages { + title := strings.ToLower(s.Title) + if strings.Contains(title, "debian") { + if strings.Contains(title, "13") || strings.Contains(title, "trixie") { + debian13 = s.UUID + } else if strings.Contains(title, "12") || strings.Contains(title, "bookworm") { + debian12 = s.UUID + } + } + } + + if debian13 != "" { + return debian13, nil + } + if debian12 != "" { + fmt.Println(" Debian 13 not available, using Debian 12") + return debian12, nil + } + + return "", fmt.Errorf("no Debian template found — check UpCloud template list") +} + +const cloudInitPath = "/var/lib/cloud/instance/scripts/part-001" + +// syncCloudInit compares a locally-generated cloud-init script against what's +// on the server. If they differ (or the remote is missing), it prompts the +// user and re-runs the script over SSH. +func syncCloudInit(name, ip, localScript string) error { + // Fetch the remote script + remoteScript, err := runSSH(ip, fmt.Sprintf("cat %s 2>/dev/null || echo '__MISSING__'", cloudInitPath), false) + if err != nil { + fmt.Printf(" cloud-init: could not reach %s (%v)\n", name, err) + return nil + } + remoteScript = strings.TrimSpace(remoteScript) + + if remoteScript == "__MISSING__" { + fmt.Printf(" cloud-init: not found on %s (server may need initial setup)\n", name) + } else { + localHash := fmt.Sprintf("%x", sha256.Sum256([]byte(localScript))) + remoteHash := fmt.Sprintf("%x", sha256.Sum256([]byte(remoteScript))) + if localHash == remoteHash { + fmt.Printf(" cloud-init: up to date\n") + return nil + } + fmt.Printf(" cloud-init: differs from local\n") + } + + fmt.Printf(" Re-run cloud-init on %s? [Y/n] ", name) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "" && answer != "y" && answer != "yes" { + fmt.Printf(" Skipped\n") + return nil + } + + fmt.Printf(" Running cloud-init on %s (%s)... (this may take several minutes)\n", name, ip) + output, err := runSSH(ip, localScript, true) + if err != nil { + fmt.Printf(" ERROR: %v\n", err) + fmt.Printf(" Output:\n%s\n", output) + return fmt.Errorf("cloud-init %s failed", name) + } + + fmt.Printf(" %s: cloud-init complete\n", name) + return nil +} diff --git a/deploy/upcloud/state.go b/deploy/upcloud/state.go new file mode 100644 index 0000000..30195da --- /dev/null +++ b/deploy/upcloud/state.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" +) + +// InfraState persists infrastructure resource UUIDs between commands. +type InfraState struct { + Zone string `json:"zone"` + ClientName string `json:"client_name,omitempty"` + RepoBranch string `json:"repo_branch,omitempty"` + Network StateRef `json:"network"` + Appview ServerState `json:"appview"` + Hold ServerState `json:"hold"` + LB StateRef `json:"loadbalancer"` + ObjectStorage ObjectStorageState `json:"object_storage"` +} + +// Naming returns a Naming helper, defaulting to "seamark" if ClientName is empty. +func (s *InfraState) Naming() Naming { + name := s.ClientName + if name == "" { + name = "seamark" + } + return Naming{ClientName: name} +} + +// Branch returns the repo branch, defaulting to "main" if empty. +func (s *InfraState) Branch() string { + if s.RepoBranch == "" { + return "main" + } + return s.RepoBranch +} + +type StateRef struct { + UUID string `json:"uuid"` +} + +type ServerState struct { + UUID string `json:"server_uuid"` + PublicIP string `json:"public_ip"` + PrivateIP string `json:"private_ip"` +} + +type ObjectStorageState struct { + UUID string `json:"uuid"` + Endpoint string `json:"endpoint"` + Region string `json:"region"` + Bucket string `json:"bucket"` + AccessKeyID string `json:"access_key_id"` +} + +func statePath() string { + _, thisFile, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(thisFile), "state.json") +} + +func loadState() (*InfraState, error) { + data, err := os.ReadFile(statePath()) + if err != nil { + return nil, fmt.Errorf("read state.json: %w (run 'provision' first)", err) + } + var st InfraState + if err := json.Unmarshal(data, &st); err != nil { + return nil, fmt.Errorf("parse state.json: %w", err) + } + return &st, nil +} + +func saveState(st *InfraState) error { + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return fmt.Errorf("marshal state: %w", err) + } + if err := os.WriteFile(statePath(), data, 0644); err != nil { + return fmt.Errorf("write state.json: %w", err) + } + return nil +} + +func deleteState() error { + if err := os.Remove(statePath()); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove state.json: %w", err) + } + return nil +} diff --git a/deploy/upcloud/status.go b/deploy/upcloud/status.go new file mode 100644 index 0000000..9537d7b --- /dev/null +++ b/deploy/upcloud/status.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show infrastructure state and health", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + token, _ := cmd.Root().PersistentFlags().GetString("token") + return cmdStatus(token) + }, +} + +func init() { + rootCmd.AddCommand(statusCmd) +} + +func cmdStatus(token string) error { + state, err := loadState() + if err != nil { + return err + } + + naming := state.Naming() + + svc, err := newService(token) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + fmt.Printf("Zone: %s\n\n", state.Zone) + + // Server status + for _, s := range []struct { + name string + ss ServerState + serviceName string + healthURL string + }{ + {"Appview", state.Appview, naming.Appview(), "http://localhost:5000/health"}, + {"Hold", state.Hold, naming.Hold(), "http://localhost:8080/xrpc/_health"}, + } { + fmt.Printf("%-8s UUID: %s\n", s.name, s.ss.UUID) + fmt.Printf(" Public: %s\n", s.ss.PublicIP) + fmt.Printf(" Private: %s\n", s.ss.PrivateIP) + + if s.ss.UUID != "" { + details, err := svc.GetServerDetails(ctx, &request.GetServerDetailsRequest{ + UUID: s.ss.UUID, + }) + if err != nil { + fmt.Printf(" State: error (%v)\n", err) + } else { + fmt.Printf(" State: %s\n", details.State) + } + } + + // SSH health check + if s.ss.PublicIP != "" { + output, err := runSSH(s.ss.PublicIP, fmt.Sprintf( + "systemctl is-active %s 2>/dev/null || echo 'inactive'; curl -sf %s > /dev/null 2>&1 && echo 'health:ok' || echo 'health:fail'", + s.serviceName, s.healthURL, + ), false) + if err != nil { + fmt.Printf(" Service: unreachable\n") + } else { + lines := strings.Split(strings.TrimSpace(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "active" || line == "inactive" { + fmt.Printf(" Service: %s\n", line) + } else if strings.HasPrefix(line, "health:") { + fmt.Printf(" Health: %s\n", strings.TrimPrefix(line, "health:")) + } + } + } + } + fmt.Println() + } + + // LB status + if state.LB.UUID != "" { + fmt.Printf("Load Balancer: %s\n", state.LB.UUID) + lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{ + UUID: state.LB.UUID, + }) + if err != nil { + fmt.Printf(" State: error (%v)\n", err) + } else { + fmt.Printf(" State: %s\n", lb.OperationalState) + for _, n := range lb.Networks { + fmt.Printf(" Network (%s): %s\n", n.Type, n.DNSName) + } + } + } + + fmt.Printf("\nNetwork: %s\n", state.Network.UUID) + + if state.ObjectStorage.UUID != "" { + fmt.Printf("\nObject Storage: %s\n", state.ObjectStorage.UUID) + fmt.Printf(" Endpoint: %s\n", state.ObjectStorage.Endpoint) + fmt.Printf(" Region: %s\n", state.ObjectStorage.Region) + fmt.Printf(" Bucket: %s\n", state.ObjectStorage.Bucket) + fmt.Printf(" Access Key: %s\n", state.ObjectStorage.AccessKeyID) + } + + return nil +} diff --git a/deploy/upcloud/systemd/appview.service.tmpl b/deploy/upcloud/systemd/appview.service.tmpl new file mode 100644 index 0000000..519d239 --- /dev/null +++ b/deploy/upcloud/systemd/appview.service.tmpl @@ -0,0 +1,25 @@ +[Unit] +Description={{.DisplayName}} AppView (Registry + Web UI) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User={{.User}} +Group={{.User}} +ExecStart={{.BinaryPath}} serve --config {{.ConfigPath}} +Restart=on-failure +RestartSec=5 + +ReadWritePaths={{.DataDir}} +ProtectSystem=strict +ProtectHome=yes +NoNewPrivileges=yes +PrivateTmp=yes + +StandardOutput=journal +StandardError=journal +SyslogIdentifier={{.ServiceName}} + +[Install] +WantedBy=multi-user.target diff --git a/deploy/upcloud/systemd/hold.service.tmpl b/deploy/upcloud/systemd/hold.service.tmpl new file mode 100644 index 0000000..b8120da --- /dev/null +++ b/deploy/upcloud/systemd/hold.service.tmpl @@ -0,0 +1,25 @@ +[Unit] +Description={{.DisplayName}} Hold (Storage Service) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User={{.User}} +Group={{.User}} +ExecStart={{.BinaryPath}} serve --config {{.ConfigPath}} +Restart=on-failure +RestartSec=5 + +ReadWritePaths={{.DataDir}} +ProtectSystem=strict +ProtectHome=yes +NoNewPrivileges=yes +PrivateTmp=yes + +StandardOutput=journal +StandardError=journal +SyslogIdentifier={{.ServiceName}} + +[Install] +WantedBy=multi-user.target diff --git a/deploy/upcloud/teardown.go b/deploy/upcloud/teardown.go new file mode 100644 index 0000000..bfe5ef7 --- /dev/null +++ b/deploy/upcloud/teardown.go @@ -0,0 +1,121 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" + "github.com/spf13/cobra" +) + +var teardownCmd = &cobra.Command{ + Use: "teardown", + Short: "Destroy all infrastructure", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + token, _ := cmd.Root().PersistentFlags().GetString("token") + return cmdTeardown(token) + }, +} + +func init() { + rootCmd.AddCommand(teardownCmd) +} + +func cmdTeardown(token string) error { + state, err := loadState() + if err != nil { + return err + } + + naming := state.Naming() + + // Confirmation prompt + fmt.Printf("This will DESTROY all %s infrastructure:\n", naming.DisplayName()) + fmt.Printf(" Zone: %s\n", state.Zone) + fmt.Printf(" Appview: %s (%s)\n", state.Appview.UUID, state.Appview.PublicIP) + fmt.Printf(" Hold: %s (%s)\n", state.Hold.UUID, state.Hold.PublicIP) + fmt.Printf(" Network: %s\n", state.Network.UUID) + fmt.Printf(" LB: %s\n", state.LB.UUID) + fmt.Println() + fmt.Print("Type 'yes' to confirm: ") + + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + if strings.TrimSpace(scanner.Text()) != "yes" { + fmt.Println("Aborted.") + return nil + } + + svc, err := newService(token) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // Delete LB first (depends on network) + if state.LB.UUID != "" { + fmt.Printf("Deleting load balancer %s...\n", state.LB.UUID) + if err := svc.DeleteLoadBalancer(ctx, &request.DeleteLoadBalancerRequest{ + UUID: state.LB.UUID, + }); err != nil { + fmt.Printf(" Warning: %v\n", err) + } + } + + // Stop and delete servers (must stop before delete, and delete storage) + for _, s := range []struct { + name string + uuid string + }{ + {"appview", state.Appview.UUID}, + {"hold", state.Hold.UUID}, + } { + if s.uuid == "" { + continue + } + fmt.Printf("Stopping server %s (%s)...\n", s.name, s.uuid) + _, err := svc.StopServer(ctx, &request.StopServerRequest{ + UUID: s.uuid, + }) + if err != nil { + fmt.Printf(" Warning (stop): %v\n", err) + } else { + svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{ + UUID: s.uuid, + DesiredState: "stopped", + }) + } + + fmt.Printf("Deleting server %s...\n", s.name) + if err := svc.DeleteServerAndStorages(ctx, &request.DeleteServerAndStoragesRequest{ + UUID: s.uuid, + }); err != nil { + fmt.Printf(" Warning (delete): %v\n", err) + } + } + + // Delete network (after servers are gone) + if state.Network.UUID != "" { + fmt.Printf("Deleting network %s...\n", state.Network.UUID) + if err := svc.DeleteNetwork(ctx, &request.DeleteNetworkRequest{ + UUID: state.Network.UUID, + }); err != nil { + fmt.Printf(" Warning: %v\n", err) + } + } + + // Remove state file + if err := deleteState(); err != nil { + return err + } + + fmt.Println("\nTeardown complete. All infrastructure destroyed.") + return nil +} diff --git a/deploy/upcloud/update.go b/deploy/upcloud/update.go new file mode 100644 index 0000000..00718d1 --- /dev/null +++ b/deploy/upcloud/update.go @@ -0,0 +1,197 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update [target]", + Short: "Deploy updates to servers", + Args: cobra.MaximumNArgs(1), + ValidArgs: []string{"all", "appview", "hold"}, + RunE: func(cmd *cobra.Command, args []string) error { + target := "all" + if len(args) > 0 { + target = args[0] + } + return cmdUpdate(target) + }, +} + +var sshCmd = &cobra.Command{ + Use: "ssh ", + Short: "SSH into a server", + Args: cobra.ExactArgs(1), + ValidArgs: []string{"appview", "hold"}, + RunE: func(cmd *cobra.Command, args []string) error { + return cmdSSH(args[0]) + }, +} + +func init() { + rootCmd.AddCommand(updateCmd) + rootCmd.AddCommand(sshCmd) +} + +func cmdUpdate(target string) error { + state, err := loadState() + if err != nil { + return err + } + + naming := state.Naming() + branch := state.Branch() + + goVersion, err := requiredGoVersion() + if err != nil { + return err + } + + targets := map[string]struct { + ip string + binaryName string + buildCmd string + serviceName string + healthURL string + }{ + "appview": { + ip: state.Appview.PublicIP, + binaryName: naming.Appview(), + buildCmd: "appview", + serviceName: naming.Appview(), + healthURL: "http://localhost:5000/health", + }, + "hold": { + ip: state.Hold.PublicIP, + binaryName: naming.Hold(), + buildCmd: "hold", + serviceName: naming.Hold(), + healthURL: "http://localhost:8080/xrpc/_health", + }, + } + + var toUpdate []string + switch target { + case "all": + toUpdate = []string{"appview", "hold"} + case "appview", "hold": + toUpdate = []string{target} + default: + return fmt.Errorf("unknown target: %s (use: all, appview, hold)", target) + } + + for _, name := range toUpdate { + t := targets[name] + fmt.Printf("Updating %s (%s)...\n", name, t.ip) + + updateScript := fmt.Sprintf(`set -euo pipefail +export PATH=$PATH:/usr/local/go/bin + +# Update Go if needed +CURRENT_GO=$(go version 2>/dev/null | grep -oP 'go\K[0-9.]+' || echo "none") +REQUIRED_GO="%s" +if [ "$CURRENT_GO" != "$REQUIRED_GO" ]; then + echo "Updating Go: $CURRENT_GO -> $REQUIRED_GO" + rm -rf /usr/local/go + curl -fsSL https://go.dev/dl/go${REQUIRED_GO}.linux-amd64.tar.gz | tar -C /usr/local -xz +fi + +cd %s +git pull origin %s +npm ci +go generate ./... +CGO_ENABLED=1 go build \ + -ldflags="-s -w -linkmode external -extldflags '-static'" \ + -tags sqlite_omit_load_extension -trimpath \ + -o bin/%s ./cmd/%s +systemctl restart %s + +sleep 2 +curl -sf %s > /dev/null && echo "HEALTH_OK" || echo "HEALTH_FAIL" +`, goVersion, naming.InstallDir(), branch, t.binaryName, t.buildCmd, t.serviceName, t.healthURL) + + output, err := runSSH(t.ip, updateScript, true) + if err != nil { + fmt.Printf(" ERROR: %v\n", err) + fmt.Printf(" Output: %s\n", output) + return fmt.Errorf("update %s failed", name) + } + + if strings.Contains(output, "HEALTH_OK") { + fmt.Printf(" %s: updated and healthy\n", name) + } else if strings.Contains(output, "HEALTH_FAIL") { + fmt.Printf(" %s: updated but health check failed!\n", name) + fmt.Printf(" Check: ssh root@%s journalctl -u %s -n 50\n", t.ip, t.serviceName) + } else { + fmt.Printf(" %s: updated (health check inconclusive)\n", name) + } + } + + return nil +} + +func cmdSSH(target string) error { + state, err := loadState() + if err != nil { + return err + } + + var ip string + switch target { + case "appview": + ip = state.Appview.PublicIP + case "hold": + ip = state.Hold.PublicIP + default: + return fmt.Errorf("unknown target: %s (use: appview, hold)", target) + } + + fmt.Printf("Connecting to %s (%s)...\n", target, ip) + cmd := exec.Command("ssh", + "-o", "StrictHostKeyChecking=accept-new", + "root@"+ip, + ) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func runSSH(ip, script string, stream bool) (string, error) { + cmd := exec.Command("ssh", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ConnectTimeout=10", + "root@"+ip, + "bash -s", + ) + cmd.Stdin = strings.NewReader(script) + + var buf bytes.Buffer + if stream { + cmd.Stdout = io.MultiWriter(os.Stdout, &buf) + cmd.Stderr = io.MultiWriter(os.Stderr, &buf) + } else { + cmd.Stdout = &buf + cmd.Stderr = &buf + } + + // Give builds up to 10 minutes + done := make(chan error, 1) + go func() { done <- cmd.Run() }() + + select { + case err := <-done: + return buf.String(), err + case <-time.After(10 * time.Minute): + cmd.Process.Kill() + return buf.String(), fmt.Errorf("SSH command timed out after 10 minutes") + } +} diff --git a/go.mod b/go.mod index e12cd2f..33c48f0 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/goki/freetype v1.0.5 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - github.com/gorilla/websocket v1.5.3 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/ipfs/go-block-format v0.2.3 github.com/ipfs/go-cid v0.6.0 github.com/ipfs/go-datastore v0.9.0 @@ -48,6 +48,7 @@ require ( ) require ( + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect github.com/ajg/form v1.6.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect @@ -73,11 +74,12 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-systemd/v22 v22.7.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect github.com/docker/go-metrics v0.0.1 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gammazero/chanqueue v1.1.1 // indirect @@ -115,6 +117,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect @@ -127,7 +130,7 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect diff --git a/go.sum b/go.sum index eeb1093..4fb715e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,4 @@ -github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8 h1:d+pBUmsteW5tM87xmVXHZ4+LibHRFn40SPAoZJOg2ak= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h1:i9fr2JpcEcY/IHEvzCM3qXUZYOQHgR89dt4es1CgMhc= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= @@ -76,11 +75,9 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= -github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= -github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= @@ -97,8 +94,7 @@ github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQ github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= @@ -164,8 +160,7 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= @@ -295,8 +290,7 @@ github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM= github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -349,8 +343,8 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= diff --git a/go.work b/go.work index ae62100..ca1736b 100644 --- a/go.work +++ b/go.work @@ -2,5 +2,6 @@ go 1.25.4 use ( . + ./deploy/upcloud ./scanner ) diff --git a/go.work.sum b/go.work.sum index 2d31594..117c713 100644 --- a/go.work.sum +++ b/go.work.sum @@ -173,11 +173,14 @@ github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/checkpoint-restore/checkpointctl v1.4.0/go.mod h1:ynQ52zQBazgcTZuxpwTFzRinIcAf0haDTC1X1LA/FKA= github.com/checkpoint-restore/go-criu/v7 v7.2.0/go.mod h1:u0LCWLg0w4yqqu14aXhiB4YD3a1qd8EcCEg7vda5dwo= github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= @@ -451,11 +454,14 @@ golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20200113040837-eac381796e91/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= diff --git a/pkg/appview/config.go b/pkg/appview/config.go index 218b8d0..2851c86 100644 --- a/pkg/appview/config.go +++ b/pkg/appview/config.go @@ -57,8 +57,8 @@ type ServerConfig struct { // Short name used in page titles and browser tabs. ClientShortName string `yaml:"client_short_name" comment:"Short name used in page titles and browser tabs."` - // Separate domain for OCI registry API. - RegistryDomain string `yaml:"registry_domain" comment:"Separate domain for OCI registry API (e.g. \"buoy.cr\"). Browser visits redirect to BaseURL."` + // Separate domains for OCI registry API. First entry is the primary (used for JWT service name and UI display). + RegistryDomains []string `yaml:"registry_domains" comment:"Separate domains for OCI registry API (e.g. [\"buoy.cr\"]). First is primary. Browser visits redirect to BaseURL."` } // UIConfig defines web UI settings @@ -145,7 +145,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("server.client_name", "AT Container Registry") v.SetDefault("server.client_short_name", "ATCR") v.SetDefault("server.oauth_key_path", "/var/lib/atcr/oauth/client.key") - v.SetDefault("server.registry_domain", "") + v.SetDefault("server.registry_domains", []string{}) // UI defaults v.SetDefault("ui.database_path", "/var/lib/atcr/ui.db") @@ -241,8 +241,8 @@ func LoadConfig(yamlPath string) (*Config, error) { // deriveServiceName extracts the JWT service name from the config. func deriveServiceName(cfg *Config) string { - if cfg.Server.RegistryDomain != "" { - return cfg.Server.RegistryDomain + if len(cfg.Server.RegistryDomains) > 0 { + return cfg.Server.RegistryDomains[0] } return getServiceName(cfg.Server.BaseURL) } diff --git a/pkg/appview/db/hold_store_test.go b/pkg/appview/db/hold_store_test.go index 49e70ac..fa24162 100644 --- a/pkg/appview/db/hold_store_test.go +++ b/pkg/appview/db/hold_store_test.go @@ -87,6 +87,8 @@ func setupHoldTestDB(t *testing.T) *sql.DB { } // Limit to single connection to avoid race conditions in tests db.SetMaxOpenConns(1) + // Clean slate: shared-cache in-memory DB may retain data from prior subtests + db.Exec("DELETE FROM hold_captain_records") t.Cleanup(func() { db.Close() }) return db } diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 9c7bd5f..6665d8b 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -240,10 +240,10 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, mainRouter.Use(routes.CORSMiddleware()) // Registry domain redirect middleware - if cfg.Server.RegistryDomain != "" { - mainRouter.Use(RegistryDomainRedirect(cfg.Server.RegistryDomain, cfg.Server.BaseURL)) + if len(cfg.Server.RegistryDomains) > 0 { + mainRouter.Use(RegistryDomainRedirect(cfg.Server.RegistryDomains, cfg.Server.BaseURL)) slog.Info("Registry domain redirect enabled", - "registry_domain", cfg.Server.RegistryDomain, + "registry_domains", cfg.Server.RegistryDomains, "ui_base_url", cfg.Server.BaseURL) } @@ -263,7 +263,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, OAuthStore: s.OAuthStore, Refresher: s.Refresher, BaseURL: baseURL, - RegistryDomain: cfg.Server.RegistryDomain, + RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains), DeviceStore: s.DeviceStore, HealthChecker: s.HealthChecker, ReadmeFetcher: s.ReadmeFetcher, @@ -499,7 +499,10 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, mainRouter.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil { + http.Error(w, "encode error", http.StatusInternalServerError) + return + } }) // Register credential helper version API (public endpoint) @@ -577,10 +580,15 @@ func (s *AppViewServer) createTokenIssuer() (*token.Issuer, error) { ) } -// RegistryDomainRedirect redirects all non-registry requests from the registry -// domain to the UI domain. Only /v2 and /v2/* pass through for Docker clients. +// RegistryDomainRedirect redirects all non-registry requests from registry +// domains to the UI domain. Only /v2 and /v2/* pass through for Docker clients. // Uses 307 (Temporary Redirect) to preserve POST method/body. -func RegistryDomainRedirect(registryDomain, uiBaseURL string) func(http.Handler) http.Handler { +func RegistryDomainRedirect(registryDomains []string, uiBaseURL string) func(http.Handler) http.Handler { + domains := make(map[string]bool, len(registryDomains)) + for _, d := range registryDomains { + domains[d] = true + } + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host := r.Host @@ -588,7 +596,7 @@ func RegistryDomainRedirect(registryDomain, uiBaseURL string) func(http.Handler) host = host[:idx] } - if host == registryDomain { + if domains[host] { path := r.URL.Path if path == "/v2" || path == "/v2/" || strings.HasPrefix(path, "/v2/") { next.ServeHTTP(w, r) @@ -605,6 +613,14 @@ func RegistryDomainRedirect(registryDomain, uiBaseURL string) func(http.Handler) } } +// primaryRegistryDomain returns the first registry domain, or empty string if none. +func primaryRegistryDomain(domains []string) string { + if len(domains) > 0 { + return domains[0] + } + return "" +} + // initializeJetstream initializes the Jetstream workers for real-time events and backfill. func (s *AppViewServer) initializeJetstream() { jetstreamURL := s.Config.Jetstream.URL diff --git a/pkg/config/marshal.go b/pkg/config/marshal.go index 105e0cf..f868e7a 100644 --- a/pkg/config/marshal.go +++ b/pkg/config/marshal.go @@ -131,6 +131,22 @@ func valueToNode(v reflect.Value) (*yaml.Node, error) { return mapToNode(v) } + // Slice → yaml sequence + if v.Kind() == reflect.Slice { + seq := &yaml.Node{ + Kind: yaml.SequenceNode, + Tag: "!!seq", + } + for i := 0; i < v.Len(); i++ { + elemNode, err := valueToNode(v.Index(i)) + if err != nil { + return nil, fmt.Errorf("slice index %d: %w", i, err) + } + seq.Content = append(seq.Content, elemNode) + } + return seq, nil + } + // Scalar types node := &yaml.Node{Kind: yaml.ScalarNode} switch v.Kind() { diff --git a/pkg/hold/billing/handlers.go b/pkg/hold/billing/handlers.go index 551cea4..ca376e6 100644 --- a/pkg/hold/billing/handlers.go +++ b/pkg/hold/billing/handlers.go @@ -114,7 +114,7 @@ func (h *XRPCHandler) HandleGetSubscriptionInfo(w http.ResponseWriter, r *http.R stats, err := h.pdsServer.GetQuotaForUserWithTier(r.Context(), userDID, h.manager.quotaMgr) if err == nil { info.CurrentUsage = stats.TotalSize - info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced) + info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced) info.CurrentLimit = stats.Limit // If no subscription but crew has a tier, show that as current diff --git a/pkg/hold/db/sqlite_store.go b/pkg/hold/db/sqlite_store.go index 883e2e9..45a0854 100644 --- a/pkg/hold/db/sqlite_store.go +++ b/pkg/hold/db/sqlite_store.go @@ -1,17 +1,16 @@ -// Package db contains a vendored from github.com/bluesky-social/indigo/carstore/sqlite_store.go +// Package db contains a vendored from github.com/bluesky-social/indigo/carstore/sqlite_store.go // Source: github.com/bluesky-social/indigo@v0.0.0-20260203235305-a86f3ae1f8ec/carstore/ // Reason: indigo's carstore hardcodes mattn/go-sqlite3, which conflicts with go-libsql // (both bundle SQLite C libraries and cannot coexist in the same binary). // // This package replaces the mattn driver with go-libsql and removes Prometheus metrics. // Once upstream accepts a driver-agnostic constructor, this vendored copy can be removed. -// Modifications: -// - Replaced mattn/go-sqlite3 driver with go-libsql -// - Removed all Prometheus metric counters and .Inc() calls -// - Changed package from 'carstore' to 'db' -// - Added NewSQLiteStoreWithDB constructor for injecting an existing *sql.DB -// - Changed sql.Open("sqlite3", path) to sql.Open("libsql", ...) with proper DSN - +// Modifications: +// - Replaced mattn/go-sqlite3 driver with go-libsql +// - Removed all Prometheus metric counters and .Inc() calls +// - Changed package from 'carstore' to 'db' +// - Added NewSQLiteStoreWithDB constructor for injecting an existing *sql.DB +// - Changed sql.Open("sqlite3", path) to sql.Open("libsql", ...) with proper DSN package db import (