| 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") |
| +} |