crabbox/internal/cli/config_cmd.go
Jonathan Moss 00725544c7
feat(azure): support linux and native windows leases
Add Azure as a managed provider for direct and brokered Crabbox leases.

- provision Azure Linux VMs with cloud-init, spot fallback, shared network adoption, and per-lease cleanup
- provision native Azure Windows VMs with VM Agent bootstrap and SSH/sync/run support
- add Azure broker support in the Cloudflare Worker, provider config, docs, and tests
- fix async Azure delete handling so successful 202 delete LROs do not refetch deleted resources
- keep Go core coverage above the CI threshold

Verified with CI plus live Azure Linux and native Windows leases.

Co-authored-by: Jonathan Moss <2729151+jwmoss@users.noreply.github.com>
2026-05-08 08:23:38 +01:00

233 lines
8.4 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
)
func (a App) configShow(args []string) error {
fs := newFlagSet("config show", a.Stderr)
jsonOut := fs.Bool("json", false, "print JSON")
if err := parseFlags(fs, args); err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
view := map[string]any{
"profile": cfg.Profile,
"provider": cfg.Provider,
"target": cfg.TargetOS,
"windowsMode": cfg.WindowsMode,
"class": cfg.Class,
"serverType": cfg.ServerType,
"serverTypeExplicit": cfg.ServerTypeExplicit,
"coordinator": cfg.Coordinator,
"brokerAuth": tokenState(cfg.CoordToken),
"brokerAdminAuth": tokenState(cfg.CoordAdminToken),
"accessAuth": accessAuthState(cfg.Access),
"sshKey": cfg.SSHKey,
"sshUser": cfg.SSHUser,
"sshPort": cfg.SSHPort,
"sshFallbackPorts": cfg.SSHFallbackPorts,
"workRoot": cfg.WorkRoot,
"sync": map[string]any{
"exclude": configuredExcludes(cfg),
"delete": cfg.Sync.Delete,
"checksum": cfg.Sync.Checksum,
"gitSeed": cfg.Sync.GitSeed,
"fingerprint": cfg.Sync.Fingerprint,
"baseRef": cfg.Sync.BaseRef,
"timeout": cfg.Sync.Timeout.String(),
"warnFiles": cfg.Sync.WarnFiles,
"warnBytes": cfg.Sync.WarnBytes,
"failFiles": cfg.Sync.FailFiles,
"failBytes": cfg.Sync.FailBytes,
"allowLarge": cfg.Sync.AllowLarge,
},
"env": map[string]any{
"allow": cfg.EnvAllow,
},
"capacity": map[string]any{
"market": cfg.Capacity.Market,
"strategy": cfg.Capacity.Strategy,
"fallback": cfg.Capacity.Fallback,
"regions": cfg.Capacity.Regions,
"availabilityZones": cfg.Capacity.AvailabilityZones,
"hints": cfg.Capacity.Hints,
},
"actions": map[string]any{
"repo": cfg.Actions.Repo,
"workflow": cfg.Actions.Workflow,
"job": cfg.Actions.Job,
"ref": cfg.Actions.Ref,
"runnerLabels": cfg.Actions.RunnerLabels,
"runnerVersion": cfg.Actions.RunnerVersion,
"ephemeral": cfg.Actions.Ephemeral,
},
"blacksmith": map[string]any{
"org": cfg.Blacksmith.Org,
"workflow": cfg.Blacksmith.Workflow,
"job": cfg.Blacksmith.Job,
"ref": cfg.Blacksmith.Ref,
"idleTimeout": cfg.Blacksmith.IdleTimeout.String(),
"debug": cfg.Blacksmith.Debug,
},
"static": map[string]any{
"id": cfg.Static.ID,
"name": cfg.Static.Name,
"host": cfg.Static.Host,
"user": cfg.Static.User,
"port": cfg.Static.Port,
"workRoot": cfg.Static.WorkRoot,
},
"results": map[string]any{
"junit": cfg.Results.JUnit,
},
"cache": map[string]any{
"pnpm": cfg.Cache.Pnpm,
"npm": cfg.Cache.Npm,
"docker": cfg.Cache.Docker,
"git": cfg.Cache.Git,
"maxGB": cfg.Cache.MaxGB,
"purgeOnRelease": cfg.Cache.PurgeOnRelease,
},
"hetzner": map[string]any{
"location": cfg.Location,
"image": cfg.Image,
"sshKey": cfg.ProviderKey,
},
"aws": map[string]any{
"region": cfg.AWSRegion,
"ami": cfg.AWSAMI,
"securityGroupId": cfg.AWSSGID,
"subnetId": cfg.AWSSubnetID,
"instanceProfile": cfg.AWSProfile,
"rootGB": cfg.AWSRootGB,
"sshCIDRs": cfg.AWSSSHCIDRs,
},
}
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(view)
}
fmt.Fprintf(a.Stdout, "config=%s\n", userConfigPath())
fmt.Fprintf(a.Stdout, "provider=%s target=%s windows_mode=%s class=%s type=%s profile=%s\n", cfg.Provider, cfg.TargetOS, cfg.WindowsMode, cfg.Class, cfg.ServerType, cfg.Profile)
fmt.Fprintf(a.Stdout, "broker=%s auth=%s admin_auth=%s\n", blank(cfg.Coordinator, "-"), tokenState(cfg.CoordToken), tokenState(cfg.CoordAdminToken))
fmt.Fprintf(a.Stdout, "access_auth=%s\n", accessAuthState(cfg.Access))
fmt.Fprintf(a.Stdout, "ssh=%s@<host>:%s fallback_ports=%s key=%s\n", cfg.SSHUser, cfg.SSHPort, blank(strings.Join(cfg.SSHFallbackPorts, ","), "-"), cfg.SSHKey)
fmt.Fprintf(a.Stdout, "sync delete=%t checksum=%t git_seed=%t fingerprint=%t base_ref=%s excludes=%d timeout=%s\n", cfg.Sync.Delete, cfg.Sync.Checksum, cfg.Sync.GitSeed, cfg.Sync.Fingerprint, blank(cfg.Sync.BaseRef, "-"), len(configuredExcludes(cfg)), cfg.Sync.Timeout)
fmt.Fprintf(a.Stdout, "env allow=%s\n", strings.Join(cfg.EnvAllow, ","))
fmt.Fprintf(a.Stdout, "capacity market=%s strategy=%s fallback=%s regions=%s hints=%t\n", cfg.Capacity.Market, cfg.Capacity.Strategy, cfg.Capacity.Fallback, blank(strings.Join(cfg.Capacity.Regions, ","), "-"), cfg.Capacity.Hints)
fmt.Fprintf(a.Stdout, "actions repo=%s workflow=%s job=%s ref=%s runner_version=%s ephemeral=%t labels=%s\n", blank(cfg.Actions.Repo, "-"), blank(cfg.Actions.Workflow, "-"), blank(cfg.Actions.Job, "-"), blank(cfg.Actions.Ref, "-"), cfg.Actions.RunnerVersion, cfg.Actions.Ephemeral, blank(strings.Join(cfg.Actions.RunnerLabels, ","), "-"))
fmt.Fprintf(a.Stdout, "blacksmith org=%s workflow=%s job=%s ref=%s idle_timeout=%s debug=%t\n", blank(cfg.Blacksmith.Org, "-"), blank(cfg.Blacksmith.Workflow, "-"), blank(cfg.Blacksmith.Job, "-"), blank(cfg.Blacksmith.Ref, "-"), cfg.Blacksmith.IdleTimeout, cfg.Blacksmith.Debug)
fmt.Fprintf(a.Stdout, "static id=%s name=%s host=%s user=%s port=%s work_root=%s\n", blank(cfg.Static.ID, "-"), blank(cfg.Static.Name, "-"), blank(cfg.Static.Host, "-"), blank(cfg.Static.User, "-"), blank(cfg.Static.Port, "-"), blank(cfg.Static.WorkRoot, "-"))
fmt.Fprintf(a.Stdout, "results junit=%s\n", blank(strings.Join(cfg.Results.JUnit, ","), "-"))
fmt.Fprintf(a.Stdout, "cache pnpm=%t npm=%t docker=%t git=%t max_gb=%d purge_on_release=%t\n", cfg.Cache.Pnpm, cfg.Cache.Npm, cfg.Cache.Docker, cfg.Cache.Git, cfg.Cache.MaxGB, cfg.Cache.PurgeOnRelease)
fmt.Fprintf(a.Stdout, "aws region=%s root_gb=%d ssh_cidrs=%s\n", cfg.AWSRegion, cfg.AWSRootGB, blank(strings.Join(cfg.AWSSSHCIDRs, ","), "-"))
return nil
}
func (a App) configSetBroker(args []string) error {
fs := newFlagSet("config set-broker", a.Stderr)
url := fs.String("url", "", "broker URL")
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
tokenStdin := fs.Bool("token-stdin", false, "read broker token from stdin")
adminTokenStdin := fs.Bool("admin-token-stdin", false, "read broker admin token from stdin")
if err := parseFlags(fs, args); err != nil {
return err
}
if *url == "" {
return exit(2, "config set-broker requires --url")
}
var token string
if *tokenStdin {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return exit(2, "read broker token: %v", err)
}
token = strings.TrimSpace(string(data))
if token == "" {
return exit(2, "broker token from stdin is empty")
}
}
var adminToken string
if *adminTokenStdin {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return exit(2, "read broker admin token: %v", err)
}
adminToken = strings.TrimSpace(string(data))
if adminToken == "" {
return exit(2, "broker admin token from stdin is empty")
}
}
path := writableConfigPath()
if path == "" {
return exit(2, "user config directory is unavailable")
}
file, err := readFileConfig(path)
if err != nil {
return err
}
if file.Broker == nil {
file.Broker = &fileBrokerConfig{}
}
file.Broker.URL = *url
if token != "" {
file.Broker.Token = token
}
if adminToken != "" {
file.Broker.AdminToken = adminToken
}
if *provider != "" {
file.Broker.Provider = *provider
file.Provider = *provider
}
written, err := writeUserFileConfig(file)
if err != nil {
return err
}
fmt.Fprintf(a.Stdout, "wrote %s broker=%s auth=%s admin_auth=%s\n", written, *url, tokenState(file.Broker.Token), tokenState(file.Broker.AdminToken))
return nil
}
func tokenState(token string) string {
if token == "" {
return "missing"
}
return "configured"
}
func accessAuthState(access AccessConfig) string {
hasServiceToken := access.ClientID != "" && access.ClientSecret != ""
hasToken := access.Token != ""
if hasServiceToken && hasToken {
return "service-token+token"
}
if hasServiceToken {
return "service-token"
}
if hasToken {
return "token"
}
if access.ClientID != "" || access.ClientSecret != "" {
return "incomplete"
}
return "missing"
}
func blank(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func Blank(value, fallback string) string {
return blank(value, fallback)
}