improve cli code

This commit is contained in:
Zoe
2025-04-13 00:53:23 -05:00
parent d501775ae6
commit 79322c4c5e
13 changed files with 265 additions and 193 deletions

View File

@@ -9,7 +9,7 @@ Flux is a lightweight self-hosted pseudo-PaaS for hosting Golang web apps with e
- Simple but powerful configuration, flux should be able to handle most use cases, from a micro web app to a fullstack app with databases, caching layers, full text search, etc.
**Limitations**:
- Theoretically only supports up to 1023 containers (roughly 500 apps assuming 2 containers per app), this is because flux uses the same bridge network for all containers (this could theoretically be increased if flux was smart enough to create new networks once we hit the max, but this is not a priority)
- Theoretically flux is likely limited by the amount of containers can fit in the bridge network, but I haven't tested this
- Containers are not particularly isolated, if one malicious container wanted to scan all containers, or interact with other containers it tectically shouldnt, it totally just can (todo?)
## Features
@@ -77,7 +77,6 @@ After=network.target
ExecStart=/usr/local/bin/fluxd
Restart=always
Environment=GOPATH=/var/fluxd/go
Environment=HOME=/var/fluxd/home
[Install]
WantedBy=multi-user.target
@@ -150,18 +149,33 @@ flux.json is the configuration file in the root of your proejct that defines dep
"name": "my-app",
"url": "myapp.example.com",
"port": 8080,
"containers": [
{
"name": "redis",
"image": "redis:latest",
"volumes": [
{
"mountpoint": "/data"
}
],
}
],
"env_file": ".env",
"environment": ["DEBUG=true"]
}
```
#### Configuration Options
The project config files has the following options:
- `name`: The name of the project
- `url`: Domain for the application
- `port`: Web server's listening port
- `env_file`: Path to environment variable file
- `environment`: Additional environment variables
| field | description | required |
| ----- | ----------- | -------- |
| `name` | The name of the project | true |
| `url` | Domain for the application | true |
| `port` | Web server's listening port | true |
| `env_file` | Path to environment variable file | false |
| `environment` | Additional environment variables | false |
| `containers` | Supplemental containers to run alongside the app | false |
| `volumes` | Volumes to mount to the app's containers | false |
## Deployment Notes

View File

@@ -1,92 +1,121 @@
package commands
import (
"bytes"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
)
func DeleteCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux delete [project-name | all]
var usage = `Usage:
flux delete [project-name | all]
Options:
project-name: The name of the project to delete
all: Delete all projects
Flux will delete the deployment of the app in the current directory or the specified project.`)
return nil
}
Options:
project-name: The name of the project to delete
all: Delete all projects
if len(args) == 1 {
if args[0] == "all" {
var response string
fmt.Print("Are you sure you want to delete all projects? this will delete all volumes and containers associated and cannot be undone. \n[y/N] ")
fmt.Scanln(&response)
Flags:
%s
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
Flux will delete the deployment of the app in the current directory or the specified project.
`
response = ""
func deleteAll(ctx models.CommandCtx, noConfirm *bool) error {
if !*noConfirm {
var response string
fmt.Print("Are you sure you want to delete all projects? this will delete all volumes and containers associated and cannot be undone. [y/N] ")
fmt.Scanln(&response)
fmt.Printf("Are you really sure you want to delete all projects? \n[y/N] ")
fmt.Scanln(&response)
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
response = ""
req, err := http.NewRequest("DELETE", config.DeamonURL+"/deployments", nil)
if err != nil {
return fmt.Errorf("failed to delete deployments: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to delete deployments: %v", err)
}
defer resp.Body.Close()
// since we are deleting **all** projects, I feel better asking for confirmation twice
fmt.Printf("Are you really sure you want to delete all projects? [y/N] ")
fmt.Scanln(&response)
if resp.StatusCode != http.StatusOK {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response body: %v", err)
}
responseBody = []byte(strings.TrimSuffix(string(responseBody), "\n"))
return fmt.Errorf("delete failed: %s", responseBody)
}
fmt.Printf("Successfully deleted all projects\n")
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
}
req, err := http.NewRequest("DELETE", ctx.Config.DeamonURL+"/deployments", nil)
if err != nil {
return fmt.Errorf("failed to delete deployments: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to delete deployments: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response body: %v", err)
}
responseBody = []byte(strings.TrimSuffix(string(responseBody), "\n"))
return fmt.Errorf("delete failed: %s", responseBody)
}
fmt.Printf("Successfully deleted all projects\n")
return nil
}
func DeleteCommand(ctx models.CommandCtx, args []string) error {
fs := flag.NewFlagSet("delete", flag.ExitOnError)
fs.Usage = func() {
var buf bytes.Buffer
// Redirect flagset to print to buffer instead of stdout
fs.SetOutput(&buf)
fs.PrintDefaults()
fmt.Printf(usage, strings.TrimRight(buf.String(), "\n"))
}
noConfirm := fs.Bool("no-confirm", false, "Skip confirmation prompt")
err := fs.Parse(args)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
args = fs.Args()
if len(args) == 1 && args[0] == "all" {
return deleteAll(ctx, noConfirm)
}
projectName, err := GetProjectName("delete", args)
if err != nil {
return err
return fmt.Errorf("\tfailed to get project name: %v.\n\tSee flux delete --help for more information", err)
}
// ask for confirmation
fmt.Printf("Are you sure you want to delete %s? this will delete all volumes and containers associated with the deployment, and cannot be undone. \n[y/N] ", projectName)
var response string
fmt.Scanln(&response)
if !*noConfirm {
fmt.Printf("Are you sure you want to delete %s? this will delete all volumes and containers associated with the deployment, and cannot be undone. \n[y/N] ", projectName)
var response string
fmt.Scanln(&response)
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
}
req, err := http.NewRequest("DELETE", config.DeamonURL+"/deployments/"+projectName, nil)
req, err := http.NewRequest("DELETE", ctx.Config.DeamonURL+"/deployments/"+projectName, nil)
if err != nil {
return fmt.Errorf("failed to delete app: %v", err)
}

View File

@@ -1,4 +1,3 @@
package commands
import (
@@ -12,9 +11,11 @@ import (
"mime/multipart"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
@@ -152,23 +153,35 @@ func compressDirectory(compression pkg.Compression) ([]byte, error) {
return buf.Bytes(), nil
}
func DeployCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux deploy
Flux will deploy the app in the current directory, and start routing traffic to it.`)
return nil
}
func DeployCommand(ctx models.CommandCtx, args []string) error {
if _, err := os.Stat("flux.json"); err != nil {
return fmt.Errorf("no flux.json found, please run flux init first")
}
spinnerWriter := models.NewCustomSpinnerWriter()
loadingSpinner := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(spinnerWriter))
defer func() {
if loadingSpinner.Active() {
loadingSpinner.Stop()
}
}()
signalChannel := make(chan os.Signal, 1)
signal.Notify(signalChannel, os.Interrupt)
go func() {
<-signalChannel
if loadingSpinner.Active() {
loadingSpinner.Stop()
}
os.Exit(0)
}()
loadingSpinner.Suffix = " Deploying"
loadingSpinner.Start()
buf, err := compressDirectory(info.Compression)
buf, err := compressDirectory(ctx.Info.Compression)
if err != nil {
return fmt.Errorf("failed to compress directory: %v", err)
}
@@ -204,7 +217,7 @@ func DeployCommand(seekingHelp bool, config models.Config, info pkg.Info, loadin
return fmt.Errorf("failed to close writer: %v", err)
}
req, err := http.NewRequest("POST", config.DeamonURL+"/deploy", body)
req, err := http.NewRequest("POST", ctx.Config.DeamonURL+"/deploy", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if err != nil {

View File

@@ -7,22 +7,21 @@ import (
"strconv"
"strings"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
)
func InitCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux init [project-name]
Options:
project-name: The name of the project to initialize
Flux will initialize a new project in the current directory or the specified project.`)
return nil
}
func InitCommand(ctx models.CommandCtx, args []string) error {
// if seekingHelp {
// fmt.Println(`Usage:
// flux init [project-name]
// Options:
// project-name: The name of the project to initialize
// Flux will initialize a new project in the current directory or the specified project.`)
// return nil
// }
var projectConfig pkg.ProjectConfig

View File

@@ -7,21 +7,12 @@ import (
"net/http"
"strings"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
)
func ListCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux list
Flux will list all the apps in the daemon.`)
return nil
}
resp, err := http.Get(config.DeamonURL + "/apps")
func ListCommand(ctx models.CommandCtx, args []string) error {
resp, err := http.Get(ctx.Config.DeamonURL + "/apps")
if err != nil {
return fmt.Errorf("failed to get apps: %v", err)
}

View File

@@ -13,7 +13,7 @@ func GetProjectName(command string, args []string) (string, error) {
if len(args) == 0 {
if _, err := os.Stat("flux.json"); err != nil {
return "", fmt.Errorf("usage: flux %[1]s <app name>, or run flux %[1]s in the project directory", command)
return "", fmt.Errorf("the current directory is not a flux project, please run flux %[1]s in the project directory", command)
}
fluxConfigFile, err := os.Open("flux.json")

View File

@@ -6,26 +6,16 @@ import (
"net/http"
"strings"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
)
func StartCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux start
Flux will start the deployment of the app in the current directory.`)
return nil
}
func StartCommand(ctx models.CommandCtx, args []string) error {
projectName, err := GetProjectName("start", args)
if err != nil {
return err
}
req, err := http.Post(config.DeamonURL+"/start/"+projectName, "application/json", nil)
req, err := http.Post(ctx.Config.DeamonURL+"/start/"+projectName, "application/json", nil)
if err != nil {
return fmt.Errorf("failed to start app: %v", err)
}

View File

@@ -6,26 +6,16 @@ import (
"net/http"
"strings"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
)
func StopCommand(seekingHelp bool, config models.Config, info pkg.Info, loadingSpinner *spinner.Spinner, spinnerWriter *models.CustomSpinnerWriter, args []string) error {
if seekingHelp {
fmt.Println(`Usage:
flux stop
Flux will stop the deployment of the app in the current directory.`)
return nil
}
func StopCommand(ctx models.CommandCtx, args []string) error {
projectName, err := GetProjectName("stop", args)
if err != nil {
return err
}
req, err := http.Post(config.DeamonURL+"/stop/"+projectName, "application/json", nil)
req, err := http.Post(ctx.Config.DeamonURL+"/stop/"+projectName, "application/json", nil)
if err != nil {
return fmt.Errorf("failed to stop app: %v", err)
}

View File

@@ -3,16 +3,14 @@ package main
import (
_ "embed"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"time"
"github.com/agnivade/levenshtein"
"github.com/briandowns/spinner"
"github.com/juls0730/flux/cmd/flux/commands"
"github.com/juls0730/flux/cmd/flux/models"
"github.com/juls0730/flux/pkg"
@@ -29,62 +27,93 @@ var helpStr = `Usage:
flux <command>
Available Commands:
init Initialize a new project
deploy Deploy a new version of the app
stop Stop a container
start Start a container
delete Delete a container
list List all containers
%s
Flags:
-h, --help help for flux
Available Flags:
--help, -h: Show this help message
Use "flux <command> --help" for more information about a command.`
Use "flux <command> --help" for more information about a command.
`
var maxDistance = 3
type CommandFunc func(models.CommandCtx, []string) error
type Command struct {
Help string
HandlerFunc CommandFunc
}
type CommandHandler struct {
commands map[string]func(bool, models.Config, pkg.Info, *spinner.Spinner, *models.CustomSpinnerWriter, []string) error
commands map[string]Command
aliases map[string]string
}
func (h *CommandHandler) RegisterCmd(name string, handler func(bool, models.Config, pkg.Info, *spinner.Spinner, *models.CustomSpinnerWriter, []string) error) {
h.commands[name] = handler
func NewCommandHandler() CommandHandler {
return CommandHandler{
commands: make(map[string]Command),
aliases: make(map[string]string),
}
}
func runCommand(command string, args []string, config models.Config, info pkg.Info, cmdHandler CommandHandler, try int) error {
if try == 2 {
return fmt.Errorf("unknown command: %s", command)
func (h *CommandHandler) RegisterCmd(name string, handler CommandFunc, help string) {
coomand := Command{
Help: help,
HandlerFunc: handler,
}
seekingHelp := false
if len(args) > 0 && (args[len(args)-1] == "--help" || args[len(args)-1] == "-h") {
seekingHelp = true
args = args[:len(args)-1]
h.commands[name] = coomand
}
func (h *CommandHandler) RegisterAlias(alias string, command string) {
h.aliases[alias] = command
}
// returns the command and whether or not it exists
func (h *CommandHandler) GetCommand(command string) (Command, bool) {
if command, ok := h.aliases[command]; ok {
return h.commands[command], true
}
spinnerWriter := models.NewCustomSpinnerWriter()
commandStruct, ok := h.commands[command]
return commandStruct, ok
}
loadingSpinner := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(spinnerWriter))
defer func() {
if loadingSpinner.Active() {
loadingSpinner.Stop()
}
}()
var helpPadding = 13
signalChannel := make(chan os.Signal, 1)
signal.Notify(signalChannel, os.Interrupt)
go func() {
<-signalChannel
if loadingSpinner.Active() {
loadingSpinner.Stop()
func (h *CommandHandler) GetHelp() {
commandsStr := ""
for command := range h.commands {
curLine := ""
curLine += command
for alias, aliasCommand := range h.aliases {
if aliasCommand == command {
curLine += fmt.Sprintf(", %s", alias)
}
}
os.Exit(0)
}()
curLine += strings.Repeat(" ", helpPadding-(len(curLine)-2))
commandsStr += fmt.Sprintf(" %s %s\n", curLine, h.commands[command].Help)
}
handler, ok := cmdHandler.commands[command]
fmt.Printf(helpStr, strings.TrimRight(commandsStr, "\n"))
}
func (h *CommandHandler) GetHelpCmd(models.CommandCtx, []string) error {
h.GetHelp()
return nil
}
func runCommand(command string, args []string, config models.Config, info pkg.Info, cmdHandler CommandHandler) error {
commandCtx := models.CommandCtx{
Config: config,
Info: info,
}
commandStruct, ok := cmdHandler.commands[command]
if ok {
return handler(seekingHelp, config, info, loadingSpinner, spinnerWriter, args)
return commandStruct.HandlerFunc(commandCtx, args)
}
// diff the command against the list of commands and if we find a command that is more than 80% similar, ask if that's what the user meant
@@ -108,7 +137,8 @@ func runCommand(command string, args []string, config models.Config, info pkg.In
}
var response string
fmt.Printf("No command found with the name '%s'. Did you mean '%s'?\n", command, closestMatch.name)
// new line ommitted because it will be produced when the user presses enter to submit their response
fmt.Printf("No command found with the name '%s'. Did you mean '%s'? (y/N)", command, closestMatch.name)
fmt.Scanln(&response)
if strings.ToLower(response) == "y" || strings.ToLower(response) == "yes" {
@@ -117,18 +147,34 @@ func runCommand(command string, args []string, config models.Config, info pkg.In
return nil
}
return runCommand(command, args, config, info, cmdHandler, try+1)
// re-run command after accepting the suggestion
return runCommand(command, args, config, info, cmdHandler)
}
func main() {
if len(os.Args) < 2 {
fmt.Println(helpStr)
cmdHandler := NewCommandHandler()
cmdHandler.RegisterCmd("init", commands.InitCommand, "Initialize a new project")
cmdHandler.RegisterCmd("deploy", commands.DeployCommand, "Deploy a new version of the app")
cmdHandler.RegisterCmd("start", commands.StartCommand, "Start the app")
cmdHandler.RegisterCmd("stop", commands.StopCommand, "Stop the app")
cmdHandler.RegisterCmd("list", commands.ListCommand, "List all the apps")
cmdHandler.RegisterCmd("delete", commands.DeleteCommand, "Delete the app")
fs := flag.NewFlagSet("flux", flag.ExitOnError)
fs.Usage = func() {
cmdHandler.GetHelp()
}
err := fs.Parse(os.Args[1:])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if os.Args[1] == "--help" || os.Args[1] == "-h" {
fmt.Println(helpStr)
os.Exit(0)
if len(os.Args) < 2 {
cmdHandler.GetHelp()
os.Exit(1)
}
if _, err := os.Stat(filepath.Join(configPath, "config.json")); err != nil {
@@ -155,9 +201,6 @@ func main() {
os.Exit(1)
}
command := os.Args[1]
args := os.Args[2:]
resp, err := http.Get(config.DeamonURL + "/heartbeat")
if err != nil {
fmt.Println("Failed to connect to daemon")
@@ -186,19 +229,9 @@ func main() {
os.Exit(1)
}
cmdHandler := CommandHandler{
commands: make(map[string]func(bool, models.Config, pkg.Info, *spinner.Spinner, *models.CustomSpinnerWriter, []string) error),
}
cmdHandler.RegisterCmd("deploy", commands.DeployCommand)
cmdHandler.RegisterCmd("stop", commands.StopCommand)
cmdHandler.RegisterCmd("start", commands.StartCommand)
cmdHandler.RegisterCmd("delete", commands.DeleteCommand)
cmdHandler.RegisterCmd("init", commands.InitCommand)
err = runCommand(command, args, config, info, cmdHandler, 0)
err = runCommand(os.Args[1], fs.Args()[1:], config, info, cmdHandler)
if err != nil {
fmt.Printf("%v\n", err)
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
}

8
cmd/flux/models/cmd.go Normal file
View File

@@ -0,0 +1,8 @@
package models
import "github.com/juls0730/flux/pkg"
type CommandCtx struct {
Config Config
Info pkg.Info
}

View File

@@ -44,11 +44,14 @@ func NewCustomStdout(spinner *CustomSpinnerWriter) *CustomStdout {
}
}
// We have this custom writer because we want to have a spinner at the bottom of the terminal, but we dont want to have
// it interfere with the output of the command
func (w *CustomStdout) Write(p []byte) (n int, err error) {
w.lock.Lock()
defer w.lock.Unlock()
n, err = os.Stdout.Write([]byte(fmt.Sprintf("\033[2K\r%s", p)))
// clear line and carriage return
n, err = os.Stdout.Write(fmt.Appendf(nil, "\033[2K\r%s", p))
if err != nil {
return n, err
}

View File

@@ -43,7 +43,8 @@ func NewDeploymentLock() *DeploymentLock {
}
}
func (dt *DeploymentLock) StartDeployment(appName string, ctx context.Context) (context.Context, error) {
// This function will lock a deployment based on an app name so that the same app cannot be deployed twice simultaneously
func (dt *DeploymentLock) LockDeployment(appName string, ctx context.Context) (context.Context, error) {
dt.mu.Lock()
defer dt.mu.Unlock()
@@ -61,7 +62,8 @@ func (dt *DeploymentLock) StartDeployment(appName string, ctx context.Context) (
return ctx, nil
}
func (dt *DeploymentLock) CompleteDeployment(appName string) {
// This function will unlock a deployment based on an app name so that the same app can be deployed again (you would call this after a deployment has completed)
func (dt *DeploymentLock) UnlockDeployment(appName string) {
dt.mu.Lock()
defer dt.mu.Unlock()
@@ -114,7 +116,7 @@ func (s *FluxServer) DeployHandler(w http.ResponseWriter, r *http.Request) {
return
}
ctx, err := deploymentLock.StartDeployment(projectConfig.Name, r.Context())
ctx, err := deploymentLock.LockDeployment(projectConfig.Name, r.Context())
if err != nil {
// This will happen if the app is already being deployed
http.Error(w, err.Error(), http.StatusConflict)
@@ -123,7 +125,7 @@ func (s *FluxServer) DeployHandler(w http.ResponseWriter, r *http.Request) {
go func() {
<-ctx.Done()
deploymentLock.CompleteDeployment(projectConfig.Name)
deploymentLock.UnlockDeployment(projectConfig.Name)
}()
flusher, ok := w.(http.Flusher)

View File

@@ -1,3 +1,3 @@
package pkg
const Version = "2bd953d"
const Version = "2025.04.13-05"