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:
22
CLAUDE.md
Normal file
22
CLAUDE.md
Normal 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
73
build.go
Normal 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
215
cert.go
Normal 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
95
config.go
Normal 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
122
deploy.go
Normal 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
64
exec.go
Normal 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
10
go.mod
Normal 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
12
go.sum
Normal 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
31
main.go
Normal 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)
|
||||||
|
}
|
||||||
51
push.go
Normal file
51
push.go
Normal 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
34
status.go
Normal 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}}'")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user