mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
services.volumes JSONB [{type: volume|bind, source, target, read_only}]
mapped into every replica's container config at deploy and reconcile
time, hashed into the runtime spec so mount changes trigger
reconciliation. Mounted via existing docker.HostConfig.Mounts.
- RuntimeSpec.Volumes + specHash + mount.Mount mapping in createReplica
- Create/update service accepts volumes; PUT replaces the whole list
- Replica-count reconcile now goes through the deploy queue — the last
raw `go` reconcile left that could race a build
- GET /admin/volumes inventories node volumes with in_use flags;
DELETE /admin/volumes/:name removes unused ones (409 on in-use)
- CLI: services update --volume/--bind/--clear-volumes, volumes
list|delete; MCP: services_update + volumes_list/delete
- Settings UI: Volumes section with mount editor
- Migration normalizes the nullable baseline column to NOT NULL [].
Verified live: deploy applies mounts to the container, docker
auto-creates named volumes, in-use deletion is refused.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
286 lines
8.0 KiB
Go
286 lines
8.0 KiB
Go
package commands
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// ServicesCmd manages services.
|
|
var ServicesCmd = &cobra.Command{
|
|
Use: "services",
|
|
Aliases: []string{"service", "svc"},
|
|
Short: "Manage services",
|
|
}
|
|
|
|
var servicesListCmd = &cobra.Command{
|
|
Use: "list [project-id]",
|
|
Short: "List services in a project",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := c.Do("GET", "/projects/"+args[0]+"/services", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items, err := unwrapList(data, "services")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows := make([][]string, 0, len(items))
|
|
for _, s := range items {
|
|
rows = append(rows, []string{
|
|
str(s, "id"), str(s, "name"), str(s, "type"), str(s, "status"), str(s, "environment"),
|
|
})
|
|
}
|
|
printRows(data, []string{"ID", "NAME", "TYPE", "STATUS", "ENV"}, rows)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var servicesGetCmd = &cobra.Command{
|
|
Use: "get <id>",
|
|
Short: "Show a service",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := c.Do("GET", "/services/"+args[0], nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if JSONMode() {
|
|
PrintRaw(data)
|
|
return nil
|
|
}
|
|
s, _ := unwrapObject(data, "service")
|
|
for _, k := range []string{"id", "name", "type", "status", "environment", "image", "git_repo", "git_branch", "domain", "port"} {
|
|
fmt.Printf("%-14s %s\n", k+":", str(s, k))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var servicesCreateCmd = &cobra.Command{
|
|
Use: "create <project-id> <name>",
|
|
Short: "Create a service (image- or git-based)",
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := map[string]interface{}{
|
|
"name": args[1],
|
|
}
|
|
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
|
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
|
key := f
|
|
// JSON uses snake_case keys matching the API schema.
|
|
switch f {
|
|
case "git-repo":
|
|
key = "git_repo"
|
|
case "git-branch":
|
|
key = "git_branch"
|
|
case "build-path":
|
|
key = "build_path"
|
|
case "restart-policy":
|
|
key = "restart_policy"
|
|
case "healthcheck-path":
|
|
key = "healthcheck_path"
|
|
}
|
|
body[key] = v
|
|
}
|
|
}
|
|
if v, _ := cmd.Flags().GetInt("port"); v > 0 {
|
|
body["port"] = v
|
|
}
|
|
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
|
|
body["replicas"] = v
|
|
}
|
|
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if JSONMode() {
|
|
PrintRaw(data)
|
|
return nil
|
|
}
|
|
s, _ := unwrapObject(data, "service")
|
|
fmt.Printf("Created service %s (%s)\n", str(s, "name"), str(s, "id"))
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var servicesDeleteCmd = &cobra.Command{
|
|
Use: "delete <id>",
|
|
Short: "Delete a service",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !Confirm(fmt.Sprintf("Delete service %s?", args[0])) {
|
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
|
}
|
|
if _, err := c.Do("DELETE", "/services/"+args[0], nil); err != nil {
|
|
return err
|
|
}
|
|
if JSONMode() {
|
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
|
}
|
|
fmt.Printf("Deleted service %s\n", args[0])
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var servicesUpdateCmd = &cobra.Command{
|
|
Use: "update <id>",
|
|
Short: "Update a service (volumes, replicas, domain, ...)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := map[string]interface{}{}
|
|
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} {
|
|
if cmd.Flags().Changed(f) {
|
|
key := f
|
|
switch f {
|
|
case "restart-policy":
|
|
key = "restart_policy"
|
|
case "healthcheck-path":
|
|
key = "healthcheck_path"
|
|
}
|
|
v, _ := cmd.Flags().GetString(f)
|
|
body[key] = v
|
|
}
|
|
}
|
|
if cmd.Flags().Changed("replicas") {
|
|
v, _ := cmd.Flags().GetInt("replicas")
|
|
body["replicas"] = v
|
|
}
|
|
if cmd.Flags().Changed("port") {
|
|
v, _ := cmd.Flags().GetInt("port")
|
|
body["port"] = v
|
|
}
|
|
if cmd.Flags().Changed("volume") || cmd.Flags().Changed("bind") || cmd.Flags().Changed("clear-volumes") {
|
|
volumes := []map[string]interface{}{}
|
|
for _, spec := range volumeFlagValues(cmd, "volume", "bind") {
|
|
volumes = append(volumes, spec)
|
|
}
|
|
body["volumes"] = volumes
|
|
}
|
|
if len(body) == 0 {
|
|
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
|
|
}
|
|
data, err := c.Do("PUT", "/services/"+args[0], body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if JSONMode() {
|
|
PrintRaw(data)
|
|
return nil
|
|
}
|
|
fmt.Printf("Updated service %s\n", args[0])
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
|
|
// objects. Bind mounts are type=bind, named volumes type=volume.
|
|
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
|
|
var out []map[string]interface{}
|
|
for _, f := range flags {
|
|
vals, _ := cmd.Flags().GetStringArray(f)
|
|
for _, v := range vals {
|
|
parts := strings.SplitN(v, ":", 3)
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
m := map[string]interface{}{
|
|
"type": f,
|
|
"source": parts[0],
|
|
"target": parts[1],
|
|
}
|
|
if len(parts) == 3 && parts[2] == "ro" {
|
|
m["read_only"] = true
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// serviceAction returns a RunE for the simple POST action endpoints.
|
|
func serviceAction(verb string) func(*cobra.Command, []string) error {
|
|
return func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := c.Do("POST", fmt.Sprintf("/services/%s/%s", args[0], verb), map[string]interface{}{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if JSONMode() {
|
|
PrintRaw(data)
|
|
return nil
|
|
}
|
|
fmt.Printf("%s: %s\n", verb, args[0])
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
f := servicesCreateCmd.Flags()
|
|
f.String("type", "web", "service type: web|worker|database|cron")
|
|
f.String("image", "", "container image (image-based service)")
|
|
f.String("git-repo", "", "git repository URL (build-from-source service)")
|
|
f.String("git-branch", "main", "git branch")
|
|
f.String("build-path", "", "build context subdirectory")
|
|
f.String("command", "", "container start command override")
|
|
f.String("environment", "production", "production|preview|development")
|
|
f.String("domain", "", "public domain")
|
|
f.String("restart-policy", "unless-stopped", "docker restart policy")
|
|
f.String("healthcheck-path", "", "HTTP health check path")
|
|
f.String("cpu", "", "CPU limit (e.g. 0.5)")
|
|
f.String("memory", "", "memory limit (e.g. 512m)")
|
|
f.Int("port", 0, "container port")
|
|
f.Int("replicas", 0, "replica count")
|
|
|
|
uf := servicesUpdateCmd.Flags()
|
|
uf.String("name", "", "service name")
|
|
uf.String("image", "", "container image")
|
|
uf.String("command", "", "container start command")
|
|
uf.String("domain", "", "public domain (empty string clears)")
|
|
uf.String("restart-policy", "", "docker restart policy")
|
|
uf.String("healthcheck-path", "", "HTTP health check path")
|
|
uf.String("cpu", "", "CPU limit (e.g. 0.5)")
|
|
uf.String("memory", "", "memory limit (e.g. 512m)")
|
|
uf.Int("replicas", 0, "replica count")
|
|
uf.Int("port", 0, "container port")
|
|
uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)")
|
|
uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)")
|
|
uf.Bool("clear-volumes", false, "remove all volume mounts")
|
|
|
|
for _, a := range []string{"start", "stop", "restart", "redeploy"} {
|
|
verb := a
|
|
ServicesCmd.AddCommand(&cobra.Command{
|
|
Use: verb + " <id>",
|
|
Short: verb + " a service",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: serviceAction(verb),
|
|
})
|
|
}
|
|
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd)
|
|
}
|