diff --git a/.gitignore b/.gitignore index adf8f72..9bca002 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ # Go workspace file go.work +# Build artifacts +spud diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a2ec433 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module spud + +go 1.22.2 + +require ( + github.com/goccy/go-yaml v1.11.3 + github.com/spf13/cobra v1.8.0 +) + +require ( + github.com/fatih/color v1.10.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.8 // indirect + github.com/mattn/go-isatty v0.0.12 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.6.0 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a75610 --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/goccy/go-yaml v1.11.3 h1:B3W9IdWbvrUu2OYQGwvU1nZtvMQJPBKgBUuweJjLj6I= +github.com/goccy/go-yaml v1.11.3/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5faf6e8 --- /dev/null +++ b/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "log" + "os" +) + +type VolumeDefinition struct { + Name string `yaml:"name"` +} + +type VolumeConfiguration struct { + Name string `yaml:"name"` + Container string `yaml:"container"` + Host string `yaml:"host"` +} + +type ContainerDefinition struct { + Name string `yaml:"name"` + Image string `yaml:"image"` + Volumes []VolumeConfiguration `yaml:"volumes"` + ExtraArgs []string `yaml:"extra-args"` +} + +type ServiceDefinition struct { + Name string `yaml:"name"` + Volumes []VolumeDefinition `yaml:"volumes"` + Containers []ContainerDefinition `yaml:"containers"` +} + +func GetServiceDefinitionFromFile(path string) ServiceDefinition { + var definition ServiceDefinition + + defData, err := os.ReadFile(path) + + // TODO: Bubble up error? + if err != nil { + log.Fatalf("Could not parse service configuration at %s: %s", path, err) + } + + if err = yaml.Unmarshal(defData, &definition); err != nil { + log.Fatalf("Could not unpack service configuration: %s", err) + } + + return definition +} + +func CreateService(definition ServiceDefinition) { + var err error + + err = CreatePod(definition.Name) + + if err != nil { + log.Fatalf("%s", err) + } + + knownVolumes := map[string]string{} + + for _, volume := range definition.Volumes { + namespacedName := definition.Name + "_" + volume.Name + CreateVolume(namespacedName, true) + knownVolumes[volume.Name] = namespacedName + } + + for _, container := range definition.Containers { + if err = CreateContainer(container, knownVolumes, definition.Name); err != nil { + log.Fatalf("%s", err) + } + } +} + +var cli = &cobra.Command{ + Use: "spud", + Short: "A not-entirely-terrible-way to manage self-hosted services.", +} + +var start = &cobra.Command{ + Use: "start", + Short: "Creates or updates a service based on the provided definition.", + Run: func(cmd *cobra.Command, args []string) { + pathProvided := args[0] + def := GetServiceDefinitionFromFile(pathProvided) + CreateService(def) + }, +} + +func main() { + cli.AddCommand(start) + cli.Execute() +} diff --git a/podman.go b/podman.go new file mode 100644 index 0000000..3db9602 --- /dev/null +++ b/podman.go @@ -0,0 +1,107 @@ +package main + +import ( + "log" + "os/exec" +) + +/* + * Creates a Podman volume of name `name` if it does not exist. + * + * If the volume exists, then behaviour depends on `existsOk`: + * - If `existsOk` is truthy, then the already-exists error is ignored and + * nothing is done; + * - Else, an error is returned. + */ +func CreateVolume(name string, existsOk bool) error { + args := []string{"volume", "create", name} + + if existsOk { + args = append(args, "--ignore") + } + + command := exec.Command("podman", args...) + + if err := command.Run(); err != nil { + + return err + } + + log.Printf("✅ Created volume \"%s\".", name) + + return nil +} + +/* + * Creates a Podman pod to keep related containers together. + * + */ +func CreatePod(name string) error { + args := []string{"pod", "create", "--replace", name} + + command := exec.Command("podman", args...) + + if err := command.Run(); err != nil { + return err + } + + log.Printf("✅ Created pod \"%s\".", name) + + return nil +} + +/* + * Creates individual containers. + */ +func CreateContainer(definition ContainerDefinition, knownVolumes map[string]string, service string) error { + namespacedContainerName := service + "_" + definition.Name + + args := []string{ + "run", + "-d", + "--name", + namespacedContainerName, + "--pod", + service, + "--replace", + } + + for _, volume := range definition.Volumes { + var host string + container := volume.Container + + if volume.Name != "" { + namespacedName := service + "_" + volume.Name + host = namespacedName + } else if volume.Host != "" { + host = volume.Host + } else { + log.Fatal("Invalid volume source configuration") + } + + arg := []string{"-v", host + ":" + container} + + args = append(args, arg...) + } + + args = append(args, definition.Image) + + for _, extra := range definition.ExtraArgs { + args = append(args, extra) + } + + command := exec.Command("podman", args...) + + if err := command.Start(); err != nil { + log.Fatal("Failed to start") + return err + } + + if err := command.Wait(); err != nil { + return err + } + + log.Printf("✅ Started container \"%s\".", namespacedContainerName) + + return nil +}