Massive architectural rework

This commit massively overhauls the project's structure to simplify
development. Most parts are now correctly compartmentalized and
dependencies are passed in a sane way rather than global variables
galore xd.
This commit is contained in:
Zoe
2025-05-02 12:15:40 -05:00
parent f4bf2ff5a1
commit c891c24843
50 changed files with 2684 additions and 2410 deletions

View File

@@ -0,0 +1,14 @@
package commands
import (
"github.com/juls0730/flux/pkg"
"github.com/juls0730/flux/pkg/API"
)
type CommandCtx struct {
Config pkg.CLIConfig
Info API.Info
Interactive bool
}
type CommandFunc func(CommandCtx, []string) error

117
cmd/cli/commands/delete.go Normal file
View File

@@ -0,0 +1,117 @@
package commands
import (
"bytes"
"flag"
"fmt"
"os"
"strings"
util "github.com/juls0730/flux/internal/util/cli"
)
var deleteUsage = `Usage:
flux delete [project-name | all]
Options:
project-name: The name of the project to delete
all: Delete all projects
Flags:
%s
Flux will delete the deployment of the app in the current directory or the specified project.`
func deleteAll(ctx CommandCtx, noConfirm *bool) error {
if !*noConfirm {
if !ctx.Interactive {
return fmt.Errorf("delete command cannot be run non-interactively without --no-confirm")
}
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)
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
response = ""
// 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 strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
}
util.DeleteRequest(ctx.Config.DaemonURL + "/deployments")
fmt.Printf("Successfully deleted all projects\n")
return nil
}
func DeleteCommand(ctx 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.Println(deleteUsage, 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)
}
project, err := util.GetProject("delete", args, ctx.Config)
if err != nil {
return fmt.Errorf("\tfailed to get project name: %v.\n\tSee flux delete --help for more information", err)
}
// ask for confirmation if not --no-confirm
if !*noConfirm {
if !ctx.Interactive {
return fmt.Errorf("delete command cannot be run non-interactively without --no-confirm")
}
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] ", project.Name)
var response string
fmt.Scanln(&response)
if strings.ToLower(response) != "y" {
fmt.Println("Aborting...")
return nil
}
}
err = util.DeleteRequest(ctx.Config.DaemonURL + "/app/" + project.Id)
if err != nil {
return fmt.Errorf("failed to delete project: %v", err)
}
if len(args) == 0 {
// remove the .fluxid file if it exists
os.Remove(".fluxid")
}
fmt.Printf("Successfully deleted %s\n", project.Name)
return nil
}

372
cmd/cli/commands/deploy.go Normal file
View File

@@ -0,0 +1,372 @@
package commands
import (
"archive/tar"
"bufio"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/briandowns/spinner"
"github.com/google/uuid"
"github.com/joho/godotenv"
util "github.com/juls0730/flux/internal/util/cli"
"github.com/juls0730/flux/pkg"
"github.com/juls0730/flux/pkg/API"
)
func matchesIgnorePattern(path string, info os.FileInfo, patterns []string) bool {
normalizedPath := filepath.ToSlash(path)
normalizedPath = strings.TrimPrefix(normalizedPath, "./")
for _, pattern := range patterns {
pattern = strings.TrimSpace(pattern)
if pattern == "" || strings.HasPrefix(pattern, "#") {
continue
}
regexPattern := convertGitignorePatternToRegex(pattern)
matched, err := regexp.MatchString(regexPattern, normalizedPath)
if err == nil && matched {
if strings.HasSuffix(pattern, "/") && info.IsDir() {
return true
}
if !info.IsDir() {
dir := filepath.Dir(normalizedPath)
for dir != "." && dir != "/" {
dirPattern := convertGitignorePatternToRegex(pattern)
if matched, _ := regexp.MatchString(dirPattern, filepath.ToSlash(dir)); matched {
return true
}
dir = filepath.Dir(dir)
}
}
return true
}
}
return false
}
func convertGitignorePatternToRegex(pattern string) string {
pattern = strings.TrimSuffix(pattern, "/")
pattern = regexp.QuoteMeta(pattern)
pattern = strings.ReplaceAll(pattern, "\\*\\*", ".*")
pattern = strings.ReplaceAll(pattern, "\\*", "[^/]*")
pattern = strings.ReplaceAll(pattern, "\\?", ".")
pattern = "(^|.*/)" + pattern + "(/.*)?$"
return pattern
}
func compressDirectory(compressionLevel int) ([]byte, error) {
var buf bytes.Buffer
var err error
var ignoredFiles []string
fluxIgnore, err := os.Open(".fluxignore")
if err != nil {
if !os.IsNotExist(err) {
return nil, err
}
}
if fluxIgnore != nil {
defer fluxIgnore.Close()
scanner := bufio.NewScanner(fluxIgnore)
for scanner.Scan() {
ignoredFiles = append(ignoredFiles, scanner.Text())
}
}
var gzWriter *gzip.Writer
if compressionLevel > 0 {
gzWriter, err = gzip.NewWriterLevel(&buf, compressionLevel)
if err != nil {
return nil, err
}
}
var tarWriter *tar.Writer
if gzWriter != nil {
tarWriter = tar.NewWriter(gzWriter)
} else {
tarWriter = tar.NewWriter(&buf)
}
err = filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if path == "flux.json" || info.IsDir() || matchesIgnorePattern(path, info, ignoredFiles) {
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = path
if err = tarWriter.WriteHeader(header); err != nil {
return err
}
if !info.IsDir() {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
if _, err = io.Copy(tarWriter, file); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
if err = tarWriter.Close(); err != nil {
return nil, err
}
if gzWriter != nil {
if err = gzWriter.Close(); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}
func preprocessEnvFile(envFile string, target *[]string) error {
envBytes, err := os.Open(envFile)
if err != nil {
return fmt.Errorf("failed to open env file: %v", err)
}
defer envBytes.Close()
envVars, err := godotenv.Parse(envBytes)
if err != nil {
return fmt.Errorf("failed to parse env file: %v", err)
}
for key, value := range envVars {
*target = append(*target, fmt.Sprintf("%s=%s", key, value))
}
return nil
}
func DeployCommand(ctx 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 := util.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(ctx.Info.CompressionLevel)
if err != nil {
return fmt.Errorf("failed to compress directory: %v", err)
}
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
if _, err := os.Stat(".fluxid"); err == nil {
idPart, err := writer.CreateFormField("id")
if err != nil {
return fmt.Errorf("failed to create id part: %v", err)
}
idFile, err := os.Open(".fluxid")
if err != nil {
return fmt.Errorf("failed to open .fluxid: %v", err)
}
defer idFile.Close()
var idBytes []byte
if idBytes, err = io.ReadAll(idFile); err != nil {
return fmt.Errorf("failed to read .fluxid: %v", err)
}
if _, err := uuid.Parse(string(idBytes)); err != nil {
return fmt.Errorf(".fluxid does not contain a valid uuid")
}
idPart.Write(idBytes)
}
configPart, err := writer.CreateFormField("config")
if err != nil {
return fmt.Errorf("failed to create config part: %v", err)
}
type FluxContainers struct {
pkg.Container
EnvFile string `json:"env_file,omitempty"`
}
type FluxConfig struct {
pkg.ProjectConfig
EnvFile string `json:"env_file,omitempty"`
Containers []FluxContainers `json:"containers,omitempty"`
}
fluxConfigFile, err := os.Open("flux.json")
if err != nil {
return fmt.Errorf("failed to open flux.json: %v", err)
}
defer fluxConfigFile.Close()
// Read the entire JSON file into a byte slice
byteValue, err := io.ReadAll(fluxConfigFile)
if err != nil {
return fmt.Errorf("failed to read flux.json: %v", err)
}
var fluxConfig FluxConfig
err = json.Unmarshal(byteValue, &fluxConfig)
if err != nil {
return fmt.Errorf("failed to unmarshal flux.json: %v", err)
}
if fluxConfig.EnvFile != "" {
if err := preprocessEnvFile(fluxConfig.EnvFile, &fluxConfig.Environment); err != nil {
return fmt.Errorf("failed to preprocess env file: %v", err)
}
}
for _, container := range fluxConfig.Containers {
if container.EnvFile != "" {
if err := preprocessEnvFile(container.EnvFile, &container.Environment); err != nil {
return fmt.Errorf("failed to preprocess env file: %v", err)
}
}
}
// write the pre-processed flux.json to the config part
if err := json.NewEncoder(configPart).Encode(fluxConfig); err != nil {
return fmt.Errorf("failed to encode flux.json: %v", err)
}
var codeFileName string
if ctx.Info.CompressionLevel > 0 {
codeFileName = "code.tar.gz"
} else {
codeFileName = "code.tar"
}
codePart, err := writer.CreateFormFile("code", codeFileName)
if err != nil {
return fmt.Errorf("failed to create code part: %v", err)
}
if _, err := codePart.Write(buf); err != nil {
return fmt.Errorf("failed to write code part: %v", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("failed to close writer: %v", err)
}
req, err := http.NewRequest("POST", ctx.Config.DaemonURL+"/deploy", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if err != nil {
return fmt.Errorf("failed to create request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
customWriter := util.NewCustomStdout(spinnerWriter)
scanner := bufio.NewScanner(resp.Body)
var event string
var data API.DeploymentEvent
var line string
for scanner.Scan() {
line = scanner.Text()
if strings.HasPrefix(line, "data: ") {
if err := json.Unmarshal([]byte(line[6:]), &data); err != nil {
return fmt.Errorf("failed to parse deployment event: %v", err)
}
switch event {
case "complete":
loadingSpinner.Stop()
fmt.Printf("App %s deployed successfully!\n", data.Message.(map[string]any)["name"])
if _, err := os.Stat(".fluxid"); os.IsNotExist(err) {
idFile, err := os.Create(".fluxid")
if err != nil {
return fmt.Errorf("failed to create .fluxid: %v", err)
}
defer idFile.Close()
id := data.Message.(map[string]any)["id"].(string)
if _, err := idFile.Write([]byte(id)); err != nil {
return fmt.Errorf("failed to write .fluxid: %v", err)
}
}
return nil
case "cmd_output":
customWriter.Printf("... %s\n", data.Message)
case "error":
loadingSpinner.Stop()
return fmt.Errorf("deployment failed: %s", data.Message)
default:
customWriter.Printf("%s\n", data.Message)
}
event = ""
} else if strings.HasPrefix(line, "event: ") {
event = strings.TrimPrefix(line, "event: ")
}
}
// the stream closed, but we didnt get a "complete" event
line = strings.TrimSuffix(line, "\n")
return fmt.Errorf("deploy failed: %s", line)
}

92
cmd/cli/commands/init.go Normal file
View File

@@ -0,0 +1,92 @@
package commands
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"strconv"
"strings"
"github.com/juls0730/flux/pkg"
)
var initUsage = `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.`
func InitCommand(ctx CommandCtx, args []string) error {
if !ctx.Interactive {
return fmt.Errorf("init command can only be run in interactive mode")
}
fs := flag.NewFlagSet("init", flag.ExitOnError)
fs.Usage = func() {
var buf bytes.Buffer
// Redirect flagset to print to buffer instead of stdout
fs.SetOutput(&buf)
fs.PrintDefaults()
fmt.Println(initUsage)
}
err := fs.Parse(args)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
args = fs.Args()
var projectConfig pkg.ProjectConfig
var response string
if len(args) > 1 {
response = args[0]
} else {
fmt.Println("What is the name of your project?")
fmt.Scanln(&response)
}
projectConfig.Name = response
fmt.Println("What URL should your project listen to?")
fmt.Scanln(&response)
if strings.HasPrefix(response, "http") {
response = strings.TrimPrefix(response, "http://")
response = strings.TrimPrefix(response, "https://")
}
response = strings.Split(response, "/")[0]
projectConfig.Url = response
fmt.Println("What port does your project listen to?")
fmt.Scanln(&response)
port, err := strconv.ParseUint(response, 10, 16)
portErr := fmt.Errorf("that doesnt look like a valid port, try a number between 1024 and 65535")
if port > 65535 {
return portErr
}
projectConfig.Port = uint16(port)
if err != nil || projectConfig.Port < 1024 {
return portErr
}
configBytes, err := json.MarshalIndent(projectConfig, "", " ")
if err != nil {
return fmt.Errorf("failed to parse project config: %v", err)
}
os.WriteFile("flux.json", configBytes, 0644)
fmt.Printf("Successfully initialized project %s\n", projectConfig.Name)
return nil
}

26
cmd/cli/commands/list.go Normal file
View File

@@ -0,0 +1,26 @@
package commands
import (
"fmt"
util "github.com/juls0730/flux/internal/util/cli"
"github.com/juls0730/flux/pkg/API"
)
func ListCommand(ctx CommandCtx, args []string) error {
apps, err := util.GetRequest[[]API.App](ctx.Config.DaemonURL + "/apps")
if err != nil {
return fmt.Errorf("failed to get apps: %v", err)
}
if len(*apps) == 0 {
fmt.Println("No apps found")
return nil
}
for _, app := range *apps {
fmt.Printf("%s (%s)\n", app.Name, app.DeploymentStatus)
}
return nil
}

22
cmd/cli/commands/start.go Normal file
View File

@@ -0,0 +1,22 @@
package commands
import (
"fmt"
util "github.com/juls0730/flux/internal/util/cli"
)
func StartCommand(ctx CommandCtx, args []string) error {
projectName, err := util.GetProject("start", args, ctx.Config)
if err != nil {
return err
}
// Put request to start the project, since the start endpoint is idempotent.
// If the project is already running, this will return a 304 Not Modified
util.PutRequest(ctx.Config.DaemonURL+"/app/"+projectName.Id+"/start", nil)
fmt.Printf("Successfully started %s\n", projectName)
return nil
}

19
cmd/cli/commands/stop.go Normal file
View File

@@ -0,0 +1,19 @@
package commands
import (
"fmt"
util "github.com/juls0730/flux/internal/util/cli"
)
func StopCommand(ctx CommandCtx, args []string) error {
projectName, err := util.GetProject("stop", args, ctx.Config)
if err != nil {
return err
}
util.PutRequest(ctx.Config.DaemonURL+"/app/"+projectName.Id+"/stop", nil)
fmt.Printf("Successfully stopped %s\n", projectName)
return nil
}