commit 8cd32cbb1cc00abf4ec03514379bcd668a948dd8 Author: Kyle Isom Date: Thu Mar 26 00:01:15 2026 -0700 Initial implementation of mcdeploy deployment tool Single Go binary with five commands: - build: podman build locally with registry tags + git version - push: podman push to MCR - deploy: SSH pull/stop/rm/run on target node - cert renew: issue TLS cert from Metacrypt via REST API - status: show container status on a node Config-driven via TOML service registry describing images, Dockerfiles, container configs per node. Shells out to podman for container operations and ssh for remote access. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..08ef1ab --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# CLAUDE.md + +## Project Overview + +mcdeploy is the Metacircular deployment CLI tool. It builds, pushes, and deploys container images to nodes, manages TLS certificates via Metacrypt, and checks container status. + +## Build Commands + +```bash +go build . # build the binary +go vet ./... # vet +``` + +## Architecture + +- **Single binary** — all code in `package main` +- **Config** — TOML-based (`mcdeploy.toml`), defines services, images, nodes, and containers +- **Commands** — `build`, `push`, `deploy`, `cert renew`, `status` +- **Exec model** — shells out to `podman`, `ssh`, `scp`, and `git` for all operations +- **No auth** — relies on SSH keys and existing podman/registry auth +- **Module path**: `git.wntrmute.dev/kyle/mcdeploy` +- **Dependencies**: cobra (CLI), go-toml/v2 (config) diff --git a/build.go b/build.go new file mode 100644 index 0000000..7fe899f --- /dev/null +++ b/build.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func buildCommand() *cobra.Command { + var imageFlag string + + cmd := &cobra.Command{ + Use: "build ", + Short: "Build container images for a service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + + svc, err := cfg.FindService(args[0]) + if err != nil { + return err + } + + svcPath := cfg.ServicePath(svc) + + version, err := runOutput(svcPath, "git", "describe", "--tags", "--always", "--dirty") + if err != nil { + return fmt.Errorf("git describe in %s: %w", svcPath, err) + } + fmt.Printf("Version: %s\n", version) + + images := svc.Images + if imageFlag != "" { + images = []string{imageFlag} + } + + var built []string + for _, image := range images { + dockerfile, ok := svc.Dockerfiles[image] + if !ok { + dockerfile = "Dockerfile" + } + + ref := cfg.ImageRef(image) + tagLatest := ref + ":latest" + tagVersion := ref + ":" + version + + err := runIn(svcPath, "podman", "build", + "-f", dockerfile, + "-t", tagLatest, + "-t", tagVersion, + ".", + ) + if err != nil { + return fmt.Errorf("build %s: %w", image, err) + } + built = append(built, tagLatest, tagVersion) + } + + fmt.Printf("\nBuilt %d image(s):\n", len(images)) + for _, tag := range built { + fmt.Printf(" %s\n", tag) + } + return nil + }, + } + + cmd.Flags().StringVar(&imageFlag, "image", "", "build only this image") + return cmd +} diff --git a/cert.go b/cert.go new file mode 100644 index 0000000..e0dd00d --- /dev/null +++ b/cert.go @@ -0,0 +1,215 @@ +package main + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" +) + +func certCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "cert", + Short: "Certificate management", + } + + cmd.AddCommand(certRenewCommand()) + return cmd +} + +func certRenewCommand() *cobra.Command { + var installFlag string + var caCertFlag string + + cmd := &cobra.Command{ + Use: "renew ", + Short: "Request a new certificate from Metacrypt", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + hostname := args[0] + + addr := os.Getenv("METACRYPT_ADDR") + if addr == "" { + addr = "https://metacrypt.svc.mcp.metacircular.net:8443" + } + token := os.Getenv("METACRYPT_TOKEN") + if token == "" { + return fmt.Errorf("METACRYPT_TOKEN environment variable is required") + } + + // Build request body. + reqBody := map[string]any{ + "mount": "pki", + "operation": "issue", + "path": "web", + "data": map[string]any{ + "issuer": "web", + "common_name": hostname, + "profile": "server", + "dns_names": []string{hostname}, + "ttl": "2160h", + }, + } + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + // Configure TLS. + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS13, + } + if caCertFlag != "" { + caPEM, err := os.ReadFile(caCertFlag) + if err != nil { + return fmt.Errorf("read CA cert: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caPEM) { + return fmt.Errorf("failed to parse CA cert") + } + tlsCfg.RootCAs = pool + } else { + tlsCfg.InsecureSkipVerify = true + } + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsCfg, + }, + } + + url := addr + "/v1/engine/request" + fmt.Printf(">>> POST %s\n", url) + + req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("metacrypt returned %d: %s", resp.StatusCode, string(respBody)) + } + + var result map[string]any + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + certPEM, _ := result["cert_pem"].(string) + chainPEM, _ := result["chain_pem"].(string) + keyPEM, _ := result["key_pem"].(string) + + if certPEM == "" || keyPEM == "" { + return fmt.Errorf("response missing cert_pem or key_pem") + } + + // Combine leaf and chain into full cert. + fullCert := certPEM + if chainPEM != "" { + fullCert = certPEM + "\n" + chainPEM + } + + // Parse and display cert details. + tlsCert, err := tls.X509KeyPair([]byte(fullCert), []byte(keyPEM)) + if err != nil { + return fmt.Errorf("parse cert: %w", err) + } + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + return fmt.Errorf("parse leaf cert: %w", err) + } + + fmt.Printf("Certificate issued:\n") + fmt.Printf(" Serial: %s\n", leaf.SerialNumber.Text(16)) + fmt.Printf(" CN: %s\n", leaf.Subject.CommonName) + fmt.Printf(" Expires: %s\n", leaf.NotAfter.Format("2006-01-02 15:04:05 UTC")) + + // Install to remote node if requested. + if installFlag != "" { + parts := strings.SplitN(installFlag, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("--install must be : (e.g. mcp:/srv/mc-proxy/tls)") + } + node := parts[0] + remotePath := parts[1] + + cfg, err := loadCfg() + if err != nil { + return err + } + + nodeCfg, err := cfg.FindNode(node) + if err != nil { + return err + } + + host := nodeCfg.Host + if nodeCfg.User != "" { + host = nodeCfg.User + "@" + nodeCfg.Host + } + + // Write temp files and SCP them. + certFile, err := os.CreateTemp("", "cert-*.pem") + if err != nil { + return fmt.Errorf("create temp cert: %w", err) + } + defer os.Remove(certFile.Name()) + + keyFile, err := os.CreateTemp("", "key-*.pem") + if err != nil { + return fmt.Errorf("create temp key: %w", err) + } + defer os.Remove(keyFile.Name()) + + if _, err := certFile.WriteString(fullCert); err != nil { + return fmt.Errorf("write temp cert: %w", err) + } + certFile.Close() + + if _, err := keyFile.WriteString(keyPEM); err != nil { + return fmt.Errorf("write temp key: %w", err) + } + keyFile.Close() + + certDest := host + ":" + remotePath + "/cert.pem" + keyDest := host + ":" + remotePath + "/key.pem" + + if err := run("scp", certFile.Name(), certDest); err != nil { + return fmt.Errorf("scp cert: %w", err) + } + if err := run("scp", keyFile.Name(), keyDest); err != nil { + return fmt.Errorf("scp key: %w", err) + } + + fmt.Printf("Installed cert and key to %s:%s/\n", node, remotePath) + } + + return nil + }, + } + + cmd.Flags().StringVar(&installFlag, "install", "", "install cert to :") + cmd.Flags().StringVar(&caCertFlag, "ca-cert", "", "CA certificate for Metacrypt TLS verification") + return cmd +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..3c8d53d --- /dev/null +++ b/config.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + toml "github.com/pelletier/go-toml/v2" +) + +// Config is the top-level deployment configuration. +type Config struct { + Workspace string `toml:"workspace"` + Registry string `toml:"registry"` + MCDSL MCDSLConfig `toml:"mcdsl"` + Services []ServiceConfig `toml:"services"` + Nodes map[string]*NodeConfig `toml:"nodes"` +} + +// MCDSLConfig points to the shared mcdsl library. +type MCDSLConfig struct { + Path string `toml:"path"` // relative to workspace +} + +// ServiceConfig describes a deployable service. +type ServiceConfig struct { + Name string `toml:"name"` + Path string `toml:"path"` // relative to workspace + Images []string `toml:"images"` + Dockerfiles map[string]string `toml:"dockerfiles"` // image name -> Dockerfile path + UsesMCDSL bool `toml:"uses_mcdsl"` +} + +// NodeConfig describes a deployment target machine. +type NodeConfig struct { + Host string `toml:"host"` + User string `toml:"user"` + Containers map[string]*ContainerConfig `toml:"containers"` +} + +// ContainerConfig describes a container to run on a node. +type ContainerConfig struct { + Image string `toml:"image"` + Network string `toml:"network"` + User string `toml:"user"` + Volumes []string `toml:"volumes"` + Ports []string `toml:"ports"` + Restart string `toml:"restart"` + Cmd []string `toml:"cmd"` +} + +// LoadConfig reads and parses a TOML config file. +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config %s: %w", path, err) + } + + var cfg Config + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config %s: %w", path, err) + } + + return &cfg, nil +} + +// FindService looks up a service by name. +func (c *Config) FindService(name string) (*ServiceConfig, error) { + for i := range c.Services { + if c.Services[i].Name == name { + return &c.Services[i], nil + } + } + return nil, fmt.Errorf("service %q not found in config", name) +} + +// FindNode looks up a node by name. +func (c *Config) FindNode(name string) (*NodeConfig, error) { + node, ok := c.Nodes[name] + if !ok { + return nil, fmt.Errorf("node %q not found in config", name) + } + return node, nil +} + +// ServicePath returns the absolute path to a service directory. +func (c *Config) ServicePath(svc *ServiceConfig) string { + return filepath.Join(c.Workspace, svc.Path) +} + +// ImageRef returns the full registry reference for an image +// (e.g. "mcr.svc.mcp.metacircular.net:8443/mc-proxy"). +func (c *Config) ImageRef(image string) string { + return c.Registry + "/" + image +} diff --git a/deploy.go b/deploy.go new file mode 100644 index 0000000..1f0654d --- /dev/null +++ b/deploy.go @@ -0,0 +1,122 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func deployCommand() *cobra.Command { + var containerFlag string + + cmd := &cobra.Command{ + Use: "deploy ", + Short: "Deploy a service to a node", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + + svc, err := cfg.FindService(args[0]) + if err != nil { + return err + } + + node, err := cfg.FindNode(args[1]) + if err != nil { + return err + } + + host := node.Host + if node.User != "" { + host = node.User + "@" + node.Host + } + + // Build the set of service images for filtering containers. + svcImages := make(map[string]bool) + for _, img := range svc.Images { + svcImages[img] = true + } + + // Collect containers belonging to this service. + type target struct { + name string + ctr *ContainerConfig + } + var targets []target + for name, ctr := range node.Containers { + if containerFlag != "" { + if name != containerFlag { + continue + } + } else if !svcImages[ctr.Image] { + continue + } + targets = append(targets, target{name: name, ctr: ctr}) + } + + if len(targets) == 0 { + return fmt.Errorf("no containers for service %q on node %q", svc.Name, args[1]) + } + + for _, t := range targets { + fmt.Printf("\n=== Deploying container %s ===\n", t.name) + + ref := cfg.ImageRef(t.ctr.Image) + ":latest" + + // Pull latest image. + if err := sshRun(host, "podman pull "+ref); err != nil { + return fmt.Errorf("pull %s: %w", ref, err) + } + + // Stop existing container (ignore errors). + _ = sshRun(host, "podman stop "+t.name) + + // Remove existing container (ignore errors). + _ = sshRun(host, "podman rm "+t.name) + + // Build podman run command. + runCmd := "podman run -d --name " + t.name + if t.ctr.Network != "" { + runCmd += " --network " + t.ctr.Network + } + if t.ctr.User != "" { + runCmd += " --user " + t.ctr.User + } + for _, vol := range t.ctr.Volumes { + runCmd += " -v " + vol + } + for _, port := range t.ctr.Ports { + runCmd += " -p " + port + } + if t.ctr.Restart != "" { + runCmd += " --restart " + t.ctr.Restart + } + runCmd += " " + ref + for _, arg := range t.ctr.Cmd { + runCmd += " " + arg + } + + // Start new container. + if err := sshRun(host, runCmd); err != nil { + return fmt.Errorf("run %s: %w", t.name, err) + } + + // Verify container is running. + status, err := sshOutput(host, "podman ps --filter name="+t.name+" --format '{{.Status}}'") + if err != nil { + return fmt.Errorf("verify %s: %w", t.name, err) + } + fmt.Printf("Status: %s\n", status) + } + + fmt.Printf("\nDeployed %d container(s) to %s\n", len(targets), args[1]) + return nil + }, + } + + cmd.Flags().StringVar(&containerFlag, "container", "", "deploy only this container") + return cmd +} diff --git a/exec.go b/exec.go new file mode 100644 index 0000000..c1ef5bd --- /dev/null +++ b/exec.go @@ -0,0 +1,64 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" +) + +// run executes a local command, streaming stdout/stderr to os.Stdout/os.Stderr. +func run(name string, args ...string) error { + fmt.Printf(">>> %s %s\n", name, strings.Join(args, " ")) + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// runIn executes a local command in a specific directory. +func runIn(dir string, name string, args ...string) error { + fmt.Printf(">>> (in %s) %s %s\n", dir, name, strings.Join(args, " ")) + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// runOutput executes a local command and returns stdout as a string. +func runOutput(dir string, name string, args ...string) (string, error) { + fmt.Printf(">>> %s %s\n", name, strings.Join(args, " ")) + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stderr = os.Stderr + var buf bytes.Buffer + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return "", err + } + return strings.TrimSpace(buf.String()), nil +} + +// sshRun executes a command on a remote host via SSH. +func sshRun(host string, command string) error { + fmt.Printf(">>> ssh %s %s\n", host, command) + cmd := exec.Command("ssh", host, command) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// sshOutput executes a command on a remote host and returns stdout. +func sshOutput(host string, command string) (string, error) { + fmt.Printf(">>> ssh %s %s\n", host, command) + cmd := exec.Command("ssh", host, command) + cmd.Stderr = os.Stderr + var buf bytes.Buffer + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return "", err + } + return strings.TrimSpace(buf.String()), nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9041fdd --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.wntrmute.dev/kyle/mcdeploy + +go 1.25.7 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4af9525 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f14251d --- /dev/null +++ b/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" +) + +var cfgPath string + +func main() { + root := &cobra.Command{ + Use: "mcdeploy", + Short: "Metacircular deployment tool", + } + root.PersistentFlags().StringVarP(&cfgPath, "config", "c", "mcdeploy.toml", "config file path") + + root.AddCommand(buildCommand()) + root.AddCommand(pushCommand()) + root.AddCommand(deployCommand()) + root.AddCommand(certCommand()) + root.AddCommand(statusCommand()) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func loadCfg() (*Config, error) { + return LoadConfig(cfgPath) +} diff --git a/mcdeploy b/mcdeploy new file mode 100755 index 0000000..6a02d48 Binary files /dev/null and b/mcdeploy differ diff --git a/push.go b/push.go new file mode 100644 index 0000000..f840fa7 --- /dev/null +++ b/push.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func pushCommand() *cobra.Command { + var imageFlag string + + cmd := &cobra.Command{ + Use: "push ", + Short: "Push container images to the registry", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + + svc, err := cfg.FindService(args[0]) + if err != nil { + return err + } + + images := svc.Images + if imageFlag != "" { + images = []string{imageFlag} + } + + var pushed []string + for _, image := range images { + ref := cfg.ImageRef(image) + ":latest" + if err := run("podman", "push", ref); err != nil { + return fmt.Errorf("push %s: %w", image, err) + } + pushed = append(pushed, ref) + } + + fmt.Printf("\nPushed %d image(s):\n", len(pushed)) + for _, ref := range pushed { + fmt.Printf(" %s\n", ref) + } + return nil + }, + } + + cmd.Flags().StringVar(&imageFlag, "image", "", "push only this image") + return cmd +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..6adc99d --- /dev/null +++ b/status.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func statusCommand() *cobra.Command { + return &cobra.Command{ + Use: "status ", + Short: "Show container status on a node", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadCfg() + if err != nil { + return err + } + + node, err := cfg.FindNode(args[0]) + if err != nil { + return err + } + + host := node.Host + if node.User != "" { + host = node.User + "@" + node.Host + } + + fmt.Printf("Containers on %s (%s):\n\n", args[0], node.Host) + return sshRun(host, "podman ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'") + }, + } +}