carlos / platform Public

feat: wire add/remove/instances, ship/promote/channels/rollback, ops verbs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 67c944faedfcce364c297a194a18fbd00122753a parent 845ffd4
2 files changed, +272 −3
  • cli.go +252 −0
  • main.go +20 −3
diff --git a/cli.go b/cli.go
new file mode 100644
index 000000000..e4b862f07
--- /dev/null
+++ b/cli.go
@@ -0,0 +1,252 @@
+// Operator CLI verbs: route management (add/remove/instances) and the
+// release pipeline (ship/promote/channels/rollback). Route verbs adapted
+// from titogo internal/carlos/cli.go — deliberately thin: a route is a row,
+// and starting the process is the agent's job.
+package main
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/version"
+)
+
+func dataDir() string {
+ if d := os.Getenv("CARLOS_DATA_DIR"); d != "" {
+ return d
+ }
+ return "/data"
+}
+
+func defaultRegistry() string { return filepath.Join(dataDir(), "registry.db") }
+
+// reservedSlugs comes from configuration, not a hardcoded list (titogo's
+// lesson: its reserved list named Tito features).
+func reservedSlugs() map[string]bool {
+ out := map[string]bool{}
+ for _, s := range strings.Split(os.Getenv("CARLOS_RESERVED_SLUGS"), ",") {
+ if s = strings.TrimSpace(s); s != "" {
+ out[s] = true
+ }
+ }
+ return out
+}
+
+func runAdd(args []string) {
+ fs := flag.NewFlagSet("add", flag.ExitOnError)
+ reg := fs.String("registry", defaultRegistry(), "registry database")
+ app := fs.String("app", "", "app whose releases this route runs")
+ slug := fs.String("slug", "", "root-domain short name (optional)")
+ kind := fs.String("kind", "instance", "instance | service | static")
+ socket := fs.String("socket", "", "unix socket upstream")
+ addr := fs.String("addr", "", "TCP upstream host:port (whole-app tenants)")
+ channel := fs.String("channel", "stable", "release channel to follow")
+ hibernate := fs.Bool("hibernate", false, "hibernate when idle")
+ dbPath := fs.String("db", "", "instance database path")
+ fs.Parse(args)
+ if fs.NArg() != 1 {
+ log.Fatal("usage: carlos add [flags] <host>")
+ }
+ host := fs.Arg(0)
+ if reservedSlugs()[*slug] {
+ log.Fatalf("slug %q is reserved", *slug)
+ }
+ if *socket == "" && *addr == "" && *kind == "instance" {
+ *socket = filepath.Join("/run/carlos", host+".sock")
+ }
+ if *dbPath == "" && *kind == "instance" {
+ *dbPath = filepath.Join(dataDir(), host+".db")
+ }
+ r, err := registry.Open(*reg)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := r.Upsert(registry.Route{
+ Host: host, Slug: *slug, Kind: *kind, Socket: *socket, Addr: *addr,
+ App: *app, Channel: *channel, Hibernate: *hibernate, DBPath: *dbPath,
+ Status: "ready",
+ }); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("added %s\n", host)
+}
+
+func runRemove(args []string) {
+ fs := flag.NewFlagSet("remove", flag.ExitOnError)
+ reg := fs.String("registry", defaultRegistry(), "registry database")
+ fs.Parse(args)
+ if fs.NArg() != 1 {
+ log.Fatal("usage: carlos remove <host>")
+ }
+ r, err := registry.Open(*reg)
+ if err != nil {
+ log.Fatal(err)
+ }
+ // Data-safe by contract: stops routing the host, never touches its DB.
+ if err := r.Delete(fs.Arg(0)); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("removed %s\n", fs.Arg(0))
+}
+
+func runInstances(args []string) {
+ fs := flag.NewFlagSet("instances", flag.ExitOnError)
+ reg := fs.String("registry", defaultRegistry(), "registry database")
+ fs.Parse(args)
+ r, err := registry.Open(*reg)
+ if err != nil {
+ log.Fatal(err)
+ }
+ routes, err := r.List()
+ if err != nil {
+ log.Fatal(err)
+ }
+ w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tSTATUS\tVERSION\tHIB")
+ for _, t := range routes {
+ hib := ""
+ if t.Hibernate {
+ hib = "yes"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ t.Host, t.App, t.Kind, t.Channel, t.Status, t.Version, hib)
+ }
+ w.Flush()
+}
+
+func openStore() release.Store {
+ if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" {
+ s, err := release.NewS3Store(context.Background(), b)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return s
+ }
+ if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" {
+ return release.NewDirStore(d)
+ }
+ log.Fatal("set CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (local)")
+ return nil
+}
+
+func signingKey() ed25519.PrivateKey {
+ k, err := release.ParseSigningKey(os.Getenv("CARLOS_RELEASE_KEY"), "CARLOS_RELEASE_KEY")
+ if err != nil {
+ log.Fatal(err)
+ }
+ return k
+}
+
+func publicKey() ed25519.PublicKey {
+ k, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+ if err != nil {
+ log.Fatal(err)
+ }
+ return k
+}
+
+func runShip(args []string) {
+ fs := flag.NewFlagSet("ship", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ kind := fs.String("kind", "binary", "binary | static")
+ ver := fs.String("version", "", "version (git short sha)")
+ fs.Parse(args)
+ if *app == "" || *ver == "" || fs.NArg() == 0 {
+ log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] <files...>")
+ }
+ files := map[string][]byte{}
+ arts := map[string]release.Artifact{}
+ for _, path := range fs.Args() {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ log.Fatal(err)
+ }
+ name := filepath.Base(path)
+ files[name] = data
+ sum := sha256.Sum256(data)
+ arts[name] = release.Artifact{File: name, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(data))}
+ }
+ m := release.Manifest{
+ Schema: 1, App: *app, Version: *ver, Kind: *kind,
+ BuiltBy: "carlos " + version.Build, BuiltAt: time.Now().UTC().Format(time.RFC3339),
+ Artifacts: arts,
+ }
+ if err := release.Ship(context.Background(), openStore(), m, files); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("shipped %s %s (%d artifacts)\n", *app, *ver, len(files))
+}
+
+func runPromote(args []string) {
+ fs := flag.NewFlagSet("promote", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ fs.Parse(args)
+ if *app == "" || fs.NArg() != 2 {
+ log.Fatal("usage: carlos promote -app <a> <version> <channel>")
+ }
+ if err := release.Promote(context.Background(), openStore(), signingKey(), *app, fs.Arg(1), fs.Arg(0)); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("promoted %s %s -> %s\n", *app, fs.Arg(0), fs.Arg(1))
+}
+
+func runChannels(args []string) {
+ fs := flag.NewFlagSet("channels", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ fs.Parse(args)
+ if *app == "" {
+ log.Fatal("usage: carlos channels -app <a>")
+ }
+ s := openStore()
+ keys, err := s.List(context.Background(), "apps/"+*app+"/channels/")
+ if err != nil {
+ log.Fatal(err)
+ }
+ pub := publicKey()
+ w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED")
+ for _, k := range keys {
+ name := strings.TrimSuffix(strings.TrimPrefix(k, "apps/"+*app+"/channels/"), ".json")
+ cp, err := release.Channel(context.Background(), s, pub, *app, name)
+ if err != nil {
+ fmt.Fprintf(w, "%s\tERROR: %v\t\n", name, err)
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n", name, cp.Version, cp.PromotedAt.Format(time.RFC3339))
+ }
+ w.Flush()
+}
+
+func runRollback(args []string) {
+ fs := flag.NewFlagSet("rollback", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ fs.Parse(args)
+ if *app == "" || fs.NArg() != 1 {
+ log.Fatal("usage: carlos rollback -app <a> <channel>")
+ }
+ if err := release.Rollback(context.Background(), openStore(), signingKey(), *app, fs.Arg(0)); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("rolled back %s %s\n", *app, fs.Arg(0))
+}
+
+func runKeygen(args []string) {
+ seed, pub, err := release.GenerateKey()
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("CARLOS_RELEASE_KEY=%s\nCARLOS_RELEASE_PUBKEY=%s\n", seed, pub)
+ fmt.Fprintln(os.Stderr, "store the KEY in your secret manager; the PUBKEY goes in host config")
+}
diff --git a/main.go b/main.go
index 41be62bfe..5d18852c9 100644
--- a/main.go
+++ b/main.go
@@ -8,6 +8,7 @@ import (
"os"
"github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/ops"
"github.com/carlosframework/platform/internal/version"
)
@@ -22,9 +23,25 @@ func main() {
fmt.Printf("carlos %s\n", version.Build)
case "edge":
edge.Run(args)
- case "agent", "add", "remove", "instances",
- "ship", "promote", "channels", "canary",
- "bootstrap", "ops", "vet":
+ case "add":
+ runAdd(args)
+ case "remove":
+ runRemove(args)
+ case "instances":
+ runInstances(args)
+ case "ship":
+ runShip(args)
+ case "promote":
+ runPromote(args)
+ case "channels":
+ runChannels(args)
+ case "rollback":
+ runRollback(args)
+ case "release-keygen":
+ runKeygen(args)
+ case "ops":
+ ops.Run(args)
+ case "agent", "canary", "bootstrap", "vet":
log.Fatalf("not implemented: %s", cmd)
default:
usage()