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) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 00:01:15 -07:00
commit 8cd32cbb1c
12 changed files with 729 additions and 0 deletions

22
CLAUDE.md Normal file
View File

@@ -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)

73
build.go Normal file
View File

@@ -0,0 +1,73 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func buildCommand() *cobra.Command {
var imageFlag string
cmd := &cobra.Command{
Use: "build <service>",
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
}

215
cert.go Normal file
View File

@@ -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 <hostname>",
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 <node>:<path> (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 <node>:<path>")
cmd.Flags().StringVar(&caCertFlag, "ca-cert", "", "CA certificate for Metacrypt TLS verification")
return cmd
}

95
config.go Normal file
View File

@@ -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
}

122
deploy.go Normal file
View File

@@ -0,0 +1,122 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func deployCommand() *cobra.Command {
var containerFlag string
cmd := &cobra.Command{
Use: "deploy <service> <node>",
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
}

64
exec.go Normal file
View File

@@ -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
}

10
go.mod Normal file
View File

@@ -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
)

12
go.sum Normal file
View File

@@ -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=

31
main.go Normal file
View File

@@ -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)
}

BIN
mcdeploy Executable file

Binary file not shown.

51
push.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func pushCommand() *cobra.Command {
var imageFlag string
cmd := &cobra.Command{
Use: "push <service>",
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
}

34
status.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func statusCommand() *cobra.Command {
return &cobra.Command{
Use: "status <node>",
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}}'")
},
}
}