mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
add new upcloud cli deploy
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <target>",
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+7
-1
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+25
-9
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user