From d088a4be7bfdd1a9fcf0ef066e5c8562b8ded942 Mon Sep 17 00:00:00 2001 From: hey Date: Tue, 7 Apr 2026 12:53:01 -0400 Subject: [PATCH] refactor api/ into cli/ and rewrite logic --- README.md | 27 ++- api/api.go | 431 +++++++------------------------------------ api/catalogue.go | 32 ++++ api/deploy.go | 299 ++++++++++++++++++++++++++++++ api/list.go | 153 +++++++++++++++ api/models.go | 21 ++- api/new.go | 32 ++++ api/undeploy.go | 66 +++++++ api/wrappers.go | 33 ++++ app.go | 4 +- cli/api.go | 157 ++++++++++++++++ cli/catalogue.go | 31 ++++ cli/deploy.go | 101 ++++++++++ cli/error.go | 13 ++ cli/list.go | 192 +++++++++++++++++++ cli/models.go | 65 +++++++ cli/new.go | 54 ++++++ cli/status/events.go | 300 ++++++++++++++++++++++++++++++ cli/status/ws.txt | 41 ++++ cli/undeploy.go | 16 ++ go.mod | 3 +- go.sum | 1 + internal/state.go | 2 +- 23 files changed, 1704 insertions(+), 370 deletions(-) create mode 100644 api/catalogue.go create mode 100644 api/deploy.go create mode 100644 api/list.go create mode 100644 api/new.go create mode 100644 api/undeploy.go create mode 100644 api/wrappers.go create mode 100644 cli/api.go create mode 100644 cli/catalogue.go create mode 100644 cli/deploy.go create mode 100644 cli/error.go create mode 100644 cli/list.go create mode 100644 cli/models.go create mode 100644 cli/new.go create mode 100644 cli/status/events.go create mode 100644 cli/status/ws.txt create mode 100644 cli/undeploy.go diff --git a/README.md b/README.md index ab1156f..21a3dd3 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,34 @@ TODO ## Getting started -- Edit the front-end application to turn off mock mode in `src/routes/Authenticated/Apps/App.tsx` and `src/routes/Authenticated/Apps/Apps.tsx` and `src/routes/Authenticated/Dashboard/Dashboard.tsx` +- Edit the front-end application to turn off mock mode in `src/routes/Authenticated/Apps/App.tsx` and `src/routes/Authenticated/Apps/Apps.tsx` and `src/routes/Authenticated/Dashboard/Dashboard.tsx` and `src/routes/Authenticated/Recipes/Recipes.tsx` - Launch the front-end application `npm run dev` - Start this Go app `go run .` - Navigate to the React App (http://localhost:5173) > [!WARNING] > This is an extremely early prototype, only viewing/deploying apps is supported -> and may fail for your local machine \ No newline at end of file +> and may fail for your local machine + +## Notes: +`api/` is a deprecated path, currently has no function + +# Roadmap +## General Roadmap: +Currently refactored the backend to be much more Abra CLI reliant. This is ok but is not ideal from an engineering standpoint (probably). +First step is just to polish this backend & frontend, estimate 1 - 2 months to be in a fairly usable state. +Write test suite for the full stack combination. +Then we want to package this app into a dockerized form, easily to deploy and use on one's own server. +Slowly introduce full Abra CLI functionality into app. +Repackaging +## Basic Abra Features +| Feature | Status | Follow-up | +| ----------- | ----------- | ----------- | +| More options for app deploy (esp chaos) | TODO | | +| View and pull in recipes from catalogue | In Progress | | +| Deploy should show deployment status similar to Abra CLI | In Progress | | +| App screen should display services associated with app and their status | TODO | | +| Easily show service logs | TODO | | +| Modify App config | TODO | | +| Manage updates and rollbacks | TODO | | +| Manage secrets and Abra commands | TODO | | diff --git a/api/api.go b/api/api.go index 699136f..a5f55b3 100644 --- a/api/api.go +++ b/api/api.go @@ -1,21 +1,43 @@ package api import ( - "context" "fmt" "log" - "encoding/json" - "coopcloud.tech/abra/pkg/client" - "coopcloud.tech/abra/pkg/upstream/stack" - - appPkg "coopcloud.tech/abra/pkg/app" - configPkg "coopcloud.tech/abra/pkg/config" - deployPkg "coopcloud.tech/abra/pkg/deploy" - - composetypes "github.com/docker/cli/cli/compose/types" "net/http" + "github.com/gorilla/websocket" "coop-cloud-backend/internal" ) +// Upgrader is used to upgrade HTTP connections to WebSocket connections. +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} +func wsHandler(w http.ResponseWriter, r *http.Request) { + // Upgrade the HTTP connection to a WebSocket connection + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + fmt.Println("Error upgrading:", err) + return + } + defer conn.Close() + // Listen for incoming messages + for { + // Read message from the client + _, message, err := conn.ReadMessage() + if err != nil { + fmt.Println("Error reading message:", err) + break + } + fmt.Printf("Received: %s\\n", message) + // Echo the message back to the client + if err := conn.WriteMessage(websocket.TextMessage, message); err != nil { + fmt.Println("Error writing message:", err) + break + } + } +} + type abraHandler struct{ mux *http.ServeMux } @@ -24,9 +46,7 @@ func newAbraHandler() *abraHandler { mux: http.NewServeMux(), } h.mux.HandleFunc("/api/abra/apps", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return @@ -39,9 +59,7 @@ func newAbraHandler() *abraHandler { } }) h.mux.HandleFunc("/api/abra/apps/{appId}/deploy", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return @@ -59,10 +77,22 @@ func newAbraHandler() *abraHandler { http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) } }) + h.mux.HandleFunc("/api/abra/apps/{appId}/stop", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + switch r.Method{ + case http.MethodPost: + h.handleUndeployApp(w, r, r.PathValue("appId")) + default: + http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) + } + }) h.mux.HandleFunc("/api/abra/servers", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return @@ -74,9 +104,29 @@ func newAbraHandler() *abraHandler { http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) } }) + h.mux.HandleFunc("/api/abra/catalogue", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + switch r.Method { + case http.MethodGet: + h.handleListCatalogue(w, r) + default: + http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) + } + }) return h } func StartAPI() { + http.HandleFunc("/ws", wsHandler) + fmt.Println("WebSocket server started on :3001") + err := http.ListenAndServe(":3001", nil) + if err != nil { + fmt.Println("Error starting server:", err) + } + h := newAbraHandler() fmt.Println("Server started on port 3000") http.ListenAndServe(":3000", h) @@ -85,354 +135,9 @@ func StartAPI() { func (h *abraHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Pre-processing: logging log.Printf("Incoming %s request: %s\n", r.Method, r.URL.Path) + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") // Delegate to internal mux h.mux.ServeHTTP(w, r) } -// func (h *appHandler) handleStartApp(w http.ResponseWriter, r *http.Request) { -// appName := r.PathValue("appName") -// w.Write([]byte("starting app: " + appName)) - -// } - -func (h *abraHandler) handleDeployApp(w http.ResponseWriter, r *http.Request, appName string) { - log.Printf("App Id: %s", appName) - app, err := GetApp(appName) - if err != nil { - log.Printf("Error getting app %s: %s\n", appName, err) - InternalServerErrorHandler(w, r) - return - } - cl, err := client.New(app.Server) - if err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName()) - if err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - if deployMeta.IsDeployed { - log.Fatal("%s is already deployed", app.Name) - InternalServerErrorHandler(w, r) - return - } - - // logic differs from CLI, we only want to take either - // 1. the chaos version - // 2. TODO: the version in the .env file - // 3. the latest version - // we never take: specific CLI verison (maybe will support this) or the deployed version - internal.Chaos = false - toDeployVersion, err := getDeployVersion(deployMeta, app) - if err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - - if err := validateSecrets(cl, app); err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - - if err := deployPkg.MergeAbraShEnv(app.Recipe, app.Env); err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - - composeFiles, err := app.Recipe.GetComposeFiles(app.Env) - if err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - - stackName := app.StackName() - deployOpts := stack.Deploy{ - Composefiles: composeFiles, - Namespace: stackName, - Prune: false, - ResolveImage: stack.ResolveImageAlways, - Detach: false, - } - compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env) - if err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - - appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name) - appPkg.SetChaosLabel(compose, stackName, internal.Chaos) - if internal.Chaos { - appPkg.SetChaosVersionLabel(compose, stackName, toDeployVersion) - } - - versionLabel := toDeployVersion - if internal.Chaos { - for _, service := range compose.Services { - if service.Name == "app" { - labelKey := fmt.Sprintf("coop-cloud.%s.version", stackName) - // NOTE(d1): keep non-chaos version labbeling when doing chaos ops - versionLabel = service.Deploy.Labels[labelKey] - } - } - } - appPkg.SetVersionLabel(compose, stackName, versionLabel) - - newRecipeWithDeployVersion := fmt.Sprintf("%s:%s", app.Recipe.Name, toDeployVersion) - appPkg.ExposeAllEnv(stackName, compose, app.Env, newRecipeWithDeployVersion) - - envVars, err := appPkg.CheckEnv(app) - if err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - // doesn't really get used at all right now - deployWarnMessages := []string{} - for _, envVar := range envVars { - if !envVar.Present { - deployWarnMessages = append(deployWarnMessages, - fmt.Sprintf("%s missing from %s.env", envVar.Name, app.Domain), - ) - } - } - - //skipping domain checks like crazy - //commented code is to show deploy overview before deploy - - /* - deployedVersion := configPkg.MISSING_DEFAULT - if deployMeta.IsDeployed { - deployedVersion = deployMeta.Version - if deployMeta.IsChaos { - deployedVersion = deployMeta.ChaosVersion - } - } - ShowUnchanged := false - secretInfo, err := deployPkg.GatherSecretsForDeploy(cl, app, ShowUnchanged) - if err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - - // Gather configs - configInfo, err := deployPkg.GatherConfigsForDeploy(cl, app, compose, app.Env, ShowUnchanged) - if err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - - // Gather images - imageInfo, err := deployPkg.GatherImagesForDeploy(cl, app, compose, ShowUnchanged) - if err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - }*/ - - stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName) - if err != nil { - log.Fatal(err) - } - - serviceNames, err := appPkg.GetAppServiceNames(app.Name) - if err != nil { - log.Fatal(err) - } - - f, err := app.Filters(true, false, serviceNames...) - if err != nil { - log.Fatal(err) - } - - // in future allow user input? - DontWaitConverge := true - NoInput := true - if err := stack.RunDeploy( - cl, - deployOpts, - compose, - app.Name, - app.Server, - DontWaitConverge, - NoInput, - f, - ); err != nil { - log.Fatal(err) - InternalServerErrorHandler(w, r) - return - } - - w.WriteHeader(http.StatusOK) -} -func (h *abraHandler) handleListApps(w http.ResponseWriter, r *http.Request) { - appNames, err := GetAppNames() - if err != nil { - log.Printf("Error getting app names: %s\n", err) - InternalServerErrorHandler(w, r) - return - } - // counts the number of apps in a server to initialize slices later - serverAppCount := make(map[string]int) - - remoteApps := make([]appPkg.App, 0, len(appNames)) - for _, appName := range appNames { - remoteApp, err := GetApp(appName) - serverAppCount[remoteApp.Server] += 1 - if err != nil { - log.Printf("Error getting app %s: %s\n", appName, err) - InternalServerErrorHandler(w, r) - return - } - remoteApps = append(remoteApps, remoteApp) - } - - appStatuses, err := GetAppStatuses(remoteApps) - if err != nil { - log.Printf("GetAppStatuses Falied\n") - fmt.Println("Error: ", err) - InternalServerErrorHandler(w, r) - return - } - abraAppResponse := ServerAppsResponse{} - for _, app := range remoteApps { - serverApps, ok := abraAppResponse[app.Server] - if !ok { // create the slice - // ever other field initializes to 0 - serverApps = ServerApps{ - Apps: make([]AbraApp, 0, serverAppCount[app.Server]), - } - } - appInfo, ok := appStatuses[app.StackName()] - if ok { - log.Printf("app %s is deployed\n", app.Name) - serverApps.Apps = append(serverApps.Apps, appTranspose(app, appInfo)) - serverApps.AppCount += 1 - // assume these are true rn idk - serverApps.VersionCount += 1 - serverApps.LatestCount += 1 - } else { - log.Printf("app %s is undeployed\n", app.Name) - serverApps.Apps = append(serverApps.Apps, appTransposeUndeployed(app)) - serverApps.AppCount += 1 - // assume these are true rn idk - serverApps.VersionCount += 1 - serverApps.LatestCount += 1 - } - abraAppResponse[app.Server] = serverApps - } - jsonBytes, err := json.Marshal(abraAppResponse) - if err != nil { - log.Printf("Error converting to json: %s\n", err) - InternalServerErrorHandler(w, r) - return - } - w.WriteHeader(http.StatusOK) - w.Write(jsonBytes) -} -func appTransposeUndeployed(app appPkg.App) AbraApp { - config, err := app.Recipe.GetComposeConfig(app.Env) - if err != nil { - log.Fatal(err) - } - version := GetLabel(config, app.StackName(), "version") - if version == "" { - version = "unknown" - } - return AbraApp{ - AppName: app.Name, - Server: app.Server, - Recipe: app.Recipe.Name, - Domain: app.Domain, - Chaos: "false", - Status: "undeployed", - ChaosVersion: "unknown", - Version: version, - Upgrade: "latest", - } -} -func appTranspose(app appPkg.App, psInfo map[string]string) AbraApp { - return AbraApp{ - AppName: app.Name, - Server: app.Server, - Recipe: app.Recipe.Name, - Domain: app.Domain, - Chaos: psInfo["chaos"], - Status: psInfo["status"], - ChaosVersion: getOrDefault(psInfo, "chaosVersion", "unknown"), - Version: psInfo["version"], - Upgrade: "latest", - } -} - -func getOrDefault(m map[string]string, key, def string) string { - if v, ok := m[key]; ok { - return v - } - return def -} - -func (h *abraHandler) handleListServers (w http.ResponseWriter, r *http.Request) { - servers, err := GetServers() - if err != nil { - InternalServerErrorHandler(w, r) - return - } - serverNames, err := ReadServerNames() - if err != nil { - InternalServerErrorHandler(w, r) - return - } - abraServers := make([]AbraServer, 0, len(servers)) - for i := range len(servers) { - abraServers = append(abraServers, - AbraServer{ - Name: serverNames[i], - Host: servers[i], - }, - ) - } - jsonBytes, err := json.Marshal(abraServers) - if err != nil { - InternalServerErrorHandler(w, r) - return - } - w.WriteHeader(http.StatusOK) - w.Write(jsonBytes) -} -func GetLabel(compose *composetypes.Config, stackName string, label string) string { - return appPkg.GetLabel(compose, stackName, label) -} - -func GetAppStatuses(apps []appPkg.App) (map[string]map[string]string, error) { - return appPkg.GetAppStatuses(apps, true) -} -func GetApp(appName string) (appPkg.App, error) { - return appPkg.Get(appName) -} - -func GetAppNames() ([]string, error) { - return appPkg.GetAppNames() -} - -func GetAppServiceNames(appName string) ([]string, error) { - return appPkg.GetAppServiceNames(appName) -} - -func GetServers() ([]string, error) { - return configPkg.GetServers() -} - -func ReadServerNames() ([]string, error) { - return configPkg.ReadServerNames() -} diff --git a/api/catalogue.go b/api/catalogue.go new file mode 100644 index 0000000..abe97be --- /dev/null +++ b/api/catalogue.go @@ -0,0 +1,32 @@ +package api +import ( + "context" + "fmt" + "log" + "encoding/json" + "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/upstream/stack" + "coopcloud.tech/abra/pkg/upstream/convert" + "coopcloud.tech/abra/pkg/recipe" + + "coop-cloud-backend/internal" +) + +func (h *abraHandler) handleListCatalogue(w http.ResponseWriter, r *http.Request) { + catalogue, err := recipe.ReadRecipeCatalogue(internal.Offline) + if err != nil { + log.Fatal(err) + } + + recipes := catalogue.Flatten() + + jsonBytes, err := json.Marshal(recipes) + if err != nil { + log.Printf("JSON conversion failed: %s\n", err) + http.Error(w, fmt.Sprintf("JSON conversion failed: %s\n", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + w.Write(jsonBytes) +} + diff --git a/api/deploy.go b/api/deploy.go new file mode 100644 index 0000000..4bdc978 --- /dev/null +++ b/api/deploy.go @@ -0,0 +1,299 @@ +package api +import ( + "context" + "fmt" + "log" + "encoding/json" + "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/upstream/stack" + "coopcloud.tech/abra/pkg/upstream/convert" + + "coopcloud.tech/abra/pkg/ui" + + appPkg "coopcloud.tech/abra/pkg/app" + "coopcloud.tech/abra/pkg/deploy" + + "net/http" + "github.com/gorilla/websocket" + tea "github.com/charmbracelet/bubbletea" + + "coop-cloud-backend/internal" +) +func (h *abraHandler) handleDeployApp(w http.ResponseWriter, r *http.Request, appName string) { + log.Printf("App Id: %s", appName) + app, err := GetApp(appName) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + cl, err := client.New(app.Server) + if err != nil { + log.Printf("Error Connecting to Docker client: %s\n", err) + http.Error(w, fmt.Sprintf("Error Connecting to Docker client: %s\n", err), http.StatusInternalServerError) + return + } + deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName()) + if err != nil { + log.Printf("Error checking deploy status: %s\n", err) + http.Error(w, fmt.Sprintf("Error checking deploy status: %s\n", err), http.StatusInternalServerError) + return + } + if deployMeta.IsDeployed { + log.Printf("App already deployed\n") + http.Error(w, "App already deployed\n", http.StatusInternalServerError) + return + } + + // logic differs from CLI, we only want to take either + // 1. the chaos version + // 2. TODO: the version in the .env file + // 3. the latest version + // we never take: specific CLI verison (maybe will support this) or the deployed version + internal.Chaos = false + toDeployVersion, err := getDeployVersion(deployMeta, app) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + if err := validateSecrets(cl, app); err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + if err := deploy.MergeAbraShEnv(app.Recipe, app.Env); err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + composeFiles, err := app.Recipe.GetComposeFiles(app.Env) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + stackName := app.StackName() + deployOpts := stack.Deploy{ + Composefiles: composeFiles, + Namespace: stackName, + Prune: false, + ResolveImage: stack.ResolveImageAlways, + Detach: false, + } + compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name) + appPkg.SetChaosLabel(compose, stackName, internal.Chaos) + if internal.Chaos { + appPkg.SetChaosVersionLabel(compose, stackName, toDeployVersion) + } + + versionLabel := toDeployVersion + if internal.Chaos { + for _, service := range compose.Services { + if service.Name == "app" { + labelKey := fmt.Sprintf("coop-cloud.%s.version", stackName) + // NOTE(d1): keep non-chaos version labbeling when doing chaos ops + versionLabel = service.Deploy.Labels[labelKey] + } + } + } + appPkg.SetVersionLabel(compose, stackName, versionLabel) + + newRecipeWithDeployVersion := fmt.Sprintf("%s:%s", app.Recipe.Name, toDeployVersion) + appPkg.ExposeAllEnv(stackName, compose, app.Env, newRecipeWithDeployVersion) + + envVars, err := appPkg.CheckEnv(app) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + // doesn't really get used at all right now + deployWarnMessages := []string{} + for _, envVar := range envVars { + if !envVar.Present { + deployWarnMessages = append(deployWarnMessages, + fmt.Sprintf("%s missing from %s.env", envVar.Name, app.Domain), + ) + } + } + + //skipping domain checks like crazy + //commented code is to show deploy overview before deploy + + /* + deployedVersion := config.MISSING_DEFAULT + if deployMeta.IsDeployed { + deployedVersion = deployMeta.Version + if deployMeta.IsChaos { + deployedVersion = deployMeta.ChaosVersion + } + } + ShowUnchanged := false + secretInfo, err := deploy.GatherSecretsForDeploy(cl, app, ShowUnchanged) + if err != nil { + log.Fatal(err) + InternalServerErrorHandler(w, r) + return + } + + // Gather configs + configInfo, err := deploy.GatherConfigsForDeploy(cl, app, compose, app.Env, ShowUnchanged) + if err != nil { + log.Fatal(err) + InternalServerErrorHandler(w, r) + return + } + + // Gather images + imageInfo, err := deploy.GatherImagesForDeploy(cl, app, compose, ShowUnchanged) + if err != nil { + log.Fatal(err) + InternalServerErrorHandler(w, r) + return + }*/ + + stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + serviceNames, err := appPkg.GetAppServiceNames(app.Name) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + f, err := app.Filters(true, false, serviceNames...) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + // in future allow user input? + DontWaitConverge := true + NoInput := true + if err := stack.RunDeploy( + cl, + deployOpts, + compose, + app.Name, + app.Server, + DontWaitConverge, + NoInput, + f, + ); err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + // Implement my own WaitOnServices in order to wrap ui.Model in order to emit JSON updates + // over Websocket connection + var serviceIDs []ui.ServiceMeta + namespace := convert.NewNamespace(deployOpts.Namespace) + + existingServices, err := stack.GetStackServices(context.Background(), cl, namespace.Name()) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + for _, service := range existingServices { + serviceIDs = append(serviceIDs, ui.ServiceMeta{ + Name: service.Spec.Name, + ID: service.ID, + }) + } + + + waitOpts := WaitOpts{ + Services: serviceIDs, + AppName: app.Name, + ServerName: app.Server, + NoInput: NoInput, + Filters: f, + } + + w.WriteHeader(http.StatusOK) +} + +type StateEmitter interface { + Emit(DeployState) +} + +type WebsocketEmitter struct { + conn *websocket.Conn +} + +func (w *WebsocketEmitter) Emit(state DeployState) { + b, _ := json.Marshal(state) + w.conn.WriteMessage(websocket.TextMessage, b) +} + +type WrappedModel struct { + inner tea.Model + emitter StateEmitter +} + +func (m WrappedModel) Init() tea.Cmd { + return m.inner.Init() +} + +func (m WrappedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + innerState, cmds := m.inner.Update(msg) + + m.inner = innerState + + m.emitter.Emit(s.State()) + + return m, cmds +} + +func (m WrappedModel) View() string { + return m.inner.View() +} + +func (m *ui.Model) State() DeployState { + var streams []DeployStream + if m.Streams != nil { + for _, s := range *m.Streams { + streams = append(streams, DeployStream{ + Name: s.Name, + id: s.id, + status: s.status, + retries: s.retries, + health: s.health, + rollback: s.rollback, + }) + } + } + var logs []string + if m.Logs != nil { + logs = *m.Logs + } + return DeployState{ + AppName: m.appName, + Streams: streams, + Logs: logs, + Failed: m.Failed, + TimedOut: m.TimedOut, + Quit: m.Quit, + Count: m.count, + } +} \ No newline at end of file diff --git a/api/list.go b/api/list.go new file mode 100644 index 0000000..c5638ff --- /dev/null +++ b/api/list.go @@ -0,0 +1,153 @@ +package api +import ( + "fmt" + "log" + "encoding/json" + appPkg "coopcloud.tech/abra/pkg/app" + "coop-cloud-backend/internal" + "net/http" +) + +func (h *abraHandler) handleListApps(w http.ResponseWriter, r *http.Request) { + appNames, err := GetAppNames() + if err != nil { + log.Printf("Error getting app names: %s\n", err) + InternalServerErrorHandler(w, r) + return + } + // counts the number of apps in a server to initialize slices later + serverAppCount := make(map[string]int) + + remoteApps := make([]appPkg.App, 0, len(appNames)) + for _, appName := range appNames { + remoteApp, err := GetApp(appName) + serverAppCount[remoteApp.Server] += 1 + if err != nil { + log.Printf("Error getting app %s: %s\n", appName, err) + InternalServerErrorHandler(w, r) + return + } + remoteApps = append(remoteApps, remoteApp) + } + + appStatuses, err := GetAppStatuses(remoteApps) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + abraAppResponse := ServerAppsResponse{} + for _, app := range remoteApps { + serverApps, ok := abraAppResponse[app.Server] + if !ok { // create the slice + // ever other field initializes to 0 + serverApps = ServerApps{ + Apps: make([]AbraApp, 0, serverAppCount[app.Server]), + } + } + appInfo, ok := appStatuses[app.StackName()] + if ok { + log.Printf("app %s is deployed\n", app.Name) + serverApps.Apps = append(serverApps.Apps, appTranspose(app, appInfo)) + serverApps.AppCount += 1 + // assume these are true rn idk + serverApps.VersionCount += 1 + serverApps.LatestCount += 1 + } else { + log.Printf("app %s is undeployed\n", app.Name) + appT, err := appTransposeUndeployed(app) + if err != nil { + log.Printf("app transpose failed: %s\n", err) + http.Error(w, fmt.Sprintf("app transpose failed: %s\n", err), http.StatusInternalServerError) + return + } + serverApps.Apps = append(serverApps.Apps, appT) + serverApps.AppCount += 1 + // assume these are true rn idk + serverApps.VersionCount += 1 + serverApps.LatestCount += 1 + } + abraAppResponse[app.Server] = serverApps + } + jsonBytes, err := json.Marshal(abraAppResponse) + if err != nil { + log.Printf("JSON conversion failed: %s\n", err) + http.Error(w, fmt.Sprintf("JSON conversion failed: %s\n", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + w.Write(jsonBytes) +} +func appTransposeUndeployed(app appPkg.App) (AbraApp, error) { + config, err := app.Recipe.GetComposeConfig(app.Env) + if err != nil { + return AbraApp{}, err + } + version := GetLabel(config, app.StackName(), "version") + if version == "" { + version = "unknown" + } + return AbraApp{ + AppName: app.Name, + Server: app.Server, + Recipe: app.Recipe.Name, + Domain: app.Domain, + Chaos: "false", + Status: "undeployed", + ChaosVersion: "unknown", + Version: version, + Upgrade: "latest", + }, nil +} +func appTranspose(app appPkg.App, psInfo map[string]string) AbraApp { + return AbraApp{ + AppName: app.Name, + Server: app.Server, + Recipe: app.Recipe.Name, + Domain: app.Domain, + Chaos: psInfo["chaos"], + Status: psInfo["status"], + ChaosVersion: getOrDefault(psInfo, "chaosVersion", "unknown"), + Version: psInfo["version"], + Upgrade: "latest", + } +} + +func getOrDefault(m map[string]string, key, def string) string { + if v, ok := m[key]; ok { + return v + } + return def +} + +func (h *abraHandler) handleListServers (w http.ResponseWriter, r *http.Request) { + servers, err := GetServers() + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + serverNames, err := ReadServerNames() + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + abraServers := make([]AbraServer, 0, len(servers)) + for i := range len(servers) { + abraServers = append(abraServers, + AbraServer{ + Name: serverNames[i], + Host: servers[i], + }, + ) + } + jsonBytes, err := json.Marshal(abraServers) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + w.Write(jsonBytes) +} \ No newline at end of file diff --git a/api/models.go b/api/models.go index 48cbe6d..c0ce3ab 100644 --- a/api/models.go +++ b/api/models.go @@ -28,4 +28,23 @@ type ServerApps struct { UnversionedCount int `json:"unversionedCount"` LatestCount int `json:"latestCount"` UpgradeCount int `json:"upgradeCount"` -} \ No newline at end of file +} + +type DeployState struct { + AppName string `json:"appName"` + Streams []DeployStream `json:"streams"` + Logs []string `json:"logs"` + Failed bool `json:"failed"` + TimedOut bool `json:"timedOut"` + Quit bool `json:"quit"` + Count int `json:"count"` +} + +type DeployStream struct { + Name string `json:"Name"` + id string `json:"id"` + status string `json:"status"` + retries int `json:"retries"` + health string `json:"health"` + rollback bool `json:"rollback"` +} \ No newline at end of file diff --git a/api/new.go b/api/new.go new file mode 100644 index 0000000..1bebe16 --- /dev/null +++ b/api/new.go @@ -0,0 +1,32 @@ +package api +import ( + "context" + "fmt" + "log" + "encoding/json" + "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/upstream/stack" + "coopcloud.tech/abra/pkg/upstream/convert" + "coopcloud.tech/abra/pkg/recipe" + + "coop-cloud-backend/internal" +) + +func (h *abraHandler) handleNewApp(w http.ResponseWriter, r *http.Request, appName string) { + catalogue, err := recipe.ReadRecipeCatalogue(internal.Offline) + if err != nil { + log.Fatal(err) + } + + recipes := catalogue.Flatten() + + jsonBytes, err := json.Marshal(recipes) + if err != nil { + log.Printf("JSON conversion failed: %s\n", err) + http.Error(w, fmt.Sprintf("JSON conversion failed: %s\n", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + w.Write(jsonBytes) +} + diff --git a/api/undeploy.go b/api/undeploy.go new file mode 100644 index 0000000..99ed3e1 --- /dev/null +++ b/api/undeploy.go @@ -0,0 +1,66 @@ +package api +import ( + "context" + "fmt" + "log" + "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/upstream/stack" + + "net/http" +) +func (h *abraHandler) handleUndeployApp(w http.ResponseWriter, r *http.Request, appName string) { + log.Printf("App Id: %s", appName) + app, err := GetApp(appName) + if err != nil { + log.Printf("Error getting app %s: %s\n", appName, err) + InternalServerErrorHandler(w, r) + return + } + // think this just checks to make sure we have the recipe for this app + if err := app.Recipe.EnsureExists(); err != nil { + log.Fatal(err) + InternalServerErrorHandler(w, r) + return + } + + cl, err := client.New(app.Server) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName()) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + if !deployMeta.IsDeployed { + log.Fatal("%s is not deployed?", app.Name) + InternalServerErrorHandler(w, r) + return + } + + version := deployMeta.Version + if deployMeta.IsChaos { + version = deployMeta.ChaosVersion + } + + rmOpts := stack.Remove{ + Namespaces: []string{app.StackName()}, + Detach: false, + } + if err := stack.RunRemove(context.Background(), cl, rmOpts); err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + if err := app.WriteRecipeVersion(version, false); err != nil { + log.Printf("writing recipe version failed: %s\n", err) + http.Error(w, fmt.Sprintf("writing recipe version failed: %s\n", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/api/wrappers.go b/api/wrappers.go new file mode 100644 index 0000000..39338d2 --- /dev/null +++ b/api/wrappers.go @@ -0,0 +1,33 @@ +package api +import ( + appPkg "coopcloud.tech/abra/pkg/app" + "coopcloud.tech/abra/pkg/config" + + composetypes "github.com/docker/cli/cli/compose/types" +) +func GetLabel(compose *composetypes.Config, stackName string, label string) string { + return appPkg.GetLabel(compose, stackName, label) +} + +func GetAppStatuses(apps []appPkg.App) (map[string]map[string]string, error) { + return appPkg.GetAppStatuses(apps, true) +} +func GetApp(appName string) (appPkg.App, error) { + return appPkg.Get(appName) +} + +func GetAppNames() ([]string, error) { + return appPkg.GetAppNames() +} + +func GetAppServiceNames(appName string) ([]string, error) { + return appPkg.GetAppServiceNames(appName) +} + +func GetServers() ([]string, error) { + return config.GetServers() +} + +func ReadServerNames() ([]string, error) { + return config.ReadServerNames() +} diff --git a/app.go b/app.go index afffd92..6758cdb 100644 --- a/app.go +++ b/app.go @@ -17,7 +17,7 @@ import ( // "github.com/spf13/cobra" // "coopcloud.tech/abra/pkg/log" - "coop-cloud-backend/api" + "coop-cloud-backend/cli" ) // getEnv reads env variables from docker services. @@ -82,5 +82,5 @@ func main() { fmt.Println(val) } - api.StartAPI() + cli.StartAPI() } diff --git a/cli/api.go b/cli/api.go new file mode 100644 index 0000000..2858681 --- /dev/null +++ b/cli/api.go @@ -0,0 +1,157 @@ +package cli +import ( + "fmt" + "log" + "net/http" + "github.com/gorilla/websocket" + + "coop-cloud-backend/internal" +) +// Upgrader is used to upgrade HTTP connections to WebSocket connections. +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} +func wsHandler(w http.ResponseWriter, r *http.Request) { + // Upgrade the HTTP connection to a WebSocket connection + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + fmt.Println("Error upgrading:", err) + return + } + defer conn.Close() + // Listen for incoming messages + for { + // Read message from the client + _, message, err := conn.ReadMessage() + if err != nil { + fmt.Println("Error reading message:", err) + break + } + fmt.Printf("Received: %s\\n", message) + // Echo the message back to the client + if err := conn.WriteMessage(websocket.TextMessage, message); err != nil { + fmt.Println("Error writing message:", err) + break + } + } +} + +type abraHandler struct{ + mux *http.ServeMux +} +func newAbraHandler() *abraHandler { + h := &abraHandler{ + mux: http.NewServeMux(), + } + h.mux.HandleFunc("/api/abra/apps", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + switch r.Method { + case http.MethodGet: + h.handleListApps(w, r) + default: + http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) + } + }) + h.mux.HandleFunc("/api/abra/apps/{appId}/deploy", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + if r.Header.Get("Chaos") == "true"{ + internal.Chaos = true + } else { + internal.Chaos = false + } + + switch r.Method{ + case http.MethodPost: + h.handleDeployApp(w, r, r.PathValue("appId")) + default: + http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) + } + }) + h.mux.HandleFunc("/api/abra/apps/{appId}/stop", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + switch r.Method{ + case http.MethodPost: + h.handleUndeployApp(w, r, r.PathValue("appId")) + default: + http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) + } + }) + h.mux.HandleFunc("/api/abra/servers", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + switch r.Method { + case http.MethodGet: + h.handleListServers(w, r) + default: + http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) + } + }) + h.mux.HandleFunc("/api/abra/catalogue", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + switch r.Method { + case http.MethodGet: + h.handleListCatalogue(w, r) + default: + http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) + } + }) + + h.mux.HandleFunc("/api/abra/apps/{appId}/new", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + switch r.Method{ + case http.MethodPost: + h.handleNewApp(w, r, r.PathValue("appId")) + default: + http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) + } + }) + return h +} +func StartAPI() { + /*http.HandleFunc("/ws", wsHandler) + fmt.Println("WebSocket server started on :3001") + err := http.ListenAndServe(":3001", nil) + if err != nil { + fmt.Println("Error starting server:", err) + }*/ + + h := newAbraHandler() + fmt.Println("Server started on port 3000") + http.ListenAndServe(":3000", h) +} + +func (h *abraHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Pre-processing: logging + log.Printf("Incoming %s request: %s\n", r.Method, r.URL.Path) + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + // Delegate to internal mux + h.mux.ServeHTTP(w, r) +} diff --git a/cli/catalogue.go b/cli/catalogue.go new file mode 100644 index 0000000..706e18a --- /dev/null +++ b/cli/catalogue.go @@ -0,0 +1,31 @@ +package cli +import ( + "log" + "fmt" + "net/http" + "slices" + "maps" + "encoding/json" + "coopcloud.tech/abra/pkg/recipe" + +) + +func (h *abraHandler) handleListCatalogue(w http.ResponseWriter, r *http.Request) { + offline := false + catl, err := recipe.ReadRecipeCatalogue(offline) + if err != nil { + log.Fatal(err) + } + vals := slices.Collect(maps.Values(catl)) + + jsonBytes, err := json.Marshal(vals) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonBytes) +} + diff --git a/cli/deploy.go b/cli/deploy.go new file mode 100644 index 0000000..ab11790 --- /dev/null +++ b/cli/deploy.go @@ -0,0 +1,101 @@ +package cli +import ( + "log" + "os/exec" + "coop-cloud-backend/internal" + "coop-cloud-backend/cli/status" + "net/http" + //"encoding/json" + "fmt" + "context" + + "coopcloud.tech/abra/pkg/client" + //"coopcloud.tech/abra/pkg/upstream/stack" + "coopcloud.tech/abra/pkg/upstream/convert" + "coopcloud.tech/abra/pkg/ui" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/swarm" + dclient "github.com/docker/docker/client" + "github.com/docker/docker/api/types" + + + appPkg "coopcloud.tech/abra/pkg/app" + + //"github.com/gorilla/websocket" +) +func (h *abraHandler) handleDeployApp(w http.ResponseWriter, r *http.Request, appName string) { + log.Printf("Handling App Deploy!") + args := []string{"app", "deploy", appName, "-n", "-c"} + if internal.Chaos { + args = append(args, "-C") + } + cmd := exec.Command("abra", args...) + output, err := cmd.Output() + if err != nil { + log.Printf("Error: ", string(output)) + InternalServerErrorHandler(w, r) + return + } + app, err := appPkg.Get(appName) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + // Implement my own WaitOnServices in order to wrap ui.Model in order to emit JSON updates + // over Websocket connection + serviceNames, err := appPkg.GetAppServiceNames(app.Name) + if err != nil { + log.Fatal(err) + } + + f, err := app.Filters(true, false, serviceNames...) + if err != nil { + log.Fatal(err) + } + + // STEP 1: collect information about app being deployed + cl, err := client.New(app.Server) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + var serviceIDs []ui.ServiceMeta + stackName := app.StackName() + namespace := convert.NewNamespace(stackName) + log.Printf("stack name: %s | namespace: %s", stackName, namespace.Name()) + existingServices, err := GetStackServices(context.Background(), cl, namespace.Name()) + if err != nil { + log.Printf("Error: %s\n", err) + http.Error(w, fmt.Sprintf("Error: %s\n", err), http.StatusInternalServerError) + return + } + + for _, service := range existingServices { + log.Printf("existingServices contains: %s", service.Spec.Name) + serviceIDs = append(serviceIDs, ui.ServiceMeta{ + Name: service.Spec.Name, + ID: service.ID, + }) + } + log.Printf("Waiting on service...") + status.WaitOnServices(context.Background(), cl, serviceIDs, f) + + w.WriteHeader(http.StatusOK) +} + + +func getStackFilter(namespace string) filters.Args { + filter := filters.NewArgs() + filter.Add("label", convert.LabelNamespace+"="+namespace) + return filter +} +func getStackServiceFilter(namespace string) filters.Args { + return getStackFilter(namespace) +} + +func GetStackServices(ctx context.Context, dockerclient dclient.APIClient, namespace string) ([]swarm.Service, error) { + return dockerclient.ServiceList(ctx, types.ServiceListOptions{Filters: getStackServiceFilter(namespace)}) +} \ No newline at end of file diff --git a/cli/error.go b/cli/error.go new file mode 100644 index 0000000..d8cb450 --- /dev/null +++ b/cli/error.go @@ -0,0 +1,13 @@ +package cli +import ( + "net/http" +) +func InternalServerErrorHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("500 Internal Server Error")) +} + +func NotFoundHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("404 Not Found")) +} \ No newline at end of file diff --git a/cli/list.go b/cli/list.go new file mode 100644 index 0000000..135e647 --- /dev/null +++ b/cli/list.go @@ -0,0 +1,192 @@ +package cli +import ( + "log" + "net/http" + "os/exec" + "encoding/json" + "strings" + "sort" + appPkg "coopcloud.tech/abra/pkg/app" + "coopcloud.tech/tagcmp" + +) + + func (h *abraHandler) handleListApps(w http.ResponseWriter, r *http.Request) { + cmd := exec.Command("abra", "app", "ls", "-m") + output, err := cmd.CombinedOutput() + if err != nil { + log.Printf("Error: ", err) + InternalServerErrorHandler(w, r) + return + } + var resp ServerAppsResponse + // no filtering + listAppServer := "" + recipeFilter := "" + + if err := json.Unmarshal(output, &resp); err != nil { + http.Error(w, "invalid JSON from CLI", http.StatusInternalServerError) + return + } + + appFiles, err := appPkg.LoadAppFiles(listAppServer) + if err != nil { + log.Fatal(err) + } + + apps, err := appPkg.GetApps(appFiles, recipeFilter) + if err != nil { + log.Fatal(err) + } + statuses := make(map[string]map[string]string) + alreadySeen := make(map[string]bool) + + for _, app := range apps { + if _, ok := alreadySeen[app.Server]; !ok { + alreadySeen[app.Server] = true + } + } + + statuses, err = appPkg.GetAppStatuses(apps, true) + if err != nil { + log.Fatal(err) + } + + for _, app := range apps { + var stats ServerApps + + stats = resp[app.Server] + var appStats *AbraApp + for _, sapp := range stats.Apps { + if sapp.AppName == app.Name { + appStats = &sapp + } + } + + status := "unknown" + version := "unknown" + chaos := "unknown" + chaosVersion := "unknown" + if statusMeta, ok := statuses[app.StackName()]; ok { + if currentVersion, exists := statusMeta["version"]; exists { + if currentVersion != "" { + version = currentVersion + } + } + if chaosDeploy, exists := statusMeta["chaos"]; exists { + chaos = chaosDeploy + } + if chaosDeployVersion, exists := statusMeta["chaosVersion"]; exists { + chaosVersion = chaosDeployVersion + } + if statusMeta["status"] != "" { + status = statusMeta["status"] + } + stats.VersionCount++ + } else { + stats.UnversionedCount++ + } + + appStats.Status = status + appStats.Chaos = chaos + appStats.ChaosVersion = chaosVersion + appStats.Version = version + localApp := true + + var newUpdates []string + if version != "unknown" && chaos == "false" { + if err := app.Recipe.EnsureExists(); err != nil { + log.Printf("unable to clone %s: %s", app.Name, err) + InternalServerErrorHandler(w, r) + return + } + + updates, err := app.Recipe.Tags() + if err != nil { + localApp = false + } else { + parsedVersion, err := tagcmp.Parse(version) + if err != nil { + log.Fatal(err) + } + + for _, update := range updates { + if ok := tagcmp.IsParsable(update); !ok { + log.Printf("unable to parse %s, skipping as upgrade option", update) + continue + } + + parsedUpdate, err := tagcmp.Parse(update) + if err != nil { + log.Fatal(err) + } + + if update != version && parsedUpdate.IsGreaterThan(parsedVersion) { + newUpdates = append(newUpdates, update) + } + } + } + } + + if len(newUpdates) == 0 { + if version == "unknown" { + appStats.Upgrade = "unknown" + } else if localApp { + appStats.Upgrade = "latest" + stats.LatestCount++ + } else { + appStats.Upgrade = "unknown" + } + } else { + newUpdates = SortVersionsDesc(newUpdates) + appStats.Upgrade = strings.Join(newUpdates, "\n") + stats.UpgradeCount++ + } + + for i, sapp := range stats.Apps { + if sapp.AppName == app.Name { + stats.Apps[i] = *appStats + } + } + resp[app.Server] = stats + } + jsonBytes, err := json.Marshal(resp) + if err != nil { + log.Printf("JSON conversion failed: %s\n", err) + InternalServerErrorHandler(w, r) + return + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonBytes) +} +func (h *abraHandler) handleListServers (w http.ResponseWriter, r *http.Request) { + cmd := exec.Command("abra", "server", "ls", "-m") + abraServers, err := cmd.CombinedOutput() + if err != nil { + log.Printf("Error: ", err) + InternalServerErrorHandler(w, r) + return + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(abraServers) +} + +func SortVersionsDesc(versions []string) []string { + var tags []tagcmp.Tag + + for _, v := range versions { + parsed, _ := tagcmp.Parse(v) // skips unsupported tags + tags = append(tags, parsed) + } + + sort.Sort(tagcmp.ByTagDesc(tags)) + + var desc []string + for _, t := range tags { + desc = append(desc, t.String()) + } + + return desc +} diff --git a/cli/models.go b/cli/models.go new file mode 100644 index 0000000..ffb51aa --- /dev/null +++ b/cli/models.go @@ -0,0 +1,65 @@ +package cli + +// Represents an App, follows the format of +// https://git.coopcloud.tech/BornDeleuze/coop-cloud-front +type AbraApp struct { + Server string `json:"server"` + Recipe string `json:"recipe` + AppName string `json:"appName"` + Domain string `json:"domain"` + Status string `json:"status"` + Chaos string `json:"chaos"` + ChaosVersion string `json:"chaosVersion"` + Version string `json:"version"` + Upgrade string `json:"upgrade"` +} + +type AbraServer struct { + Name string `json:"name"` + Host string `json:"host"` +} + +type ServerAppsResponse map[string]ServerApps + +type ServerApps struct { + Apps []AbraApp `json:"apps"` + AppCount int `json:"appCount"` + VersionCount int `json:"versionCount"` + UnversionedCount int `json:"unversionedCount"` + LatestCount int `json:"latestCount"` + UpgradeCount int `json:"upgradeCount"` +} + +type DeployState struct { + AppName string `json:"appName"` + Streams []DeployStream `json:"streams"` + Logs []string `json:"logs"` + Failed bool `json:"failed"` + TimedOut bool `json:"timedOut"` + Quit bool `json:"quit"` + Count int `json:"count"` +} + +type DeployStream struct { + Name string `json:"Name"` + id string `json:"id"` + status string `json:"status"` + retries int `json:"retries"` + health string `json:"health"` + rollback bool `json:"rollback"` +} + +// type RecipeMeta struct { +// Category string `json:"category"` +// DefaultBranch string `json:"default_branch"` +// Description string `json:"description"` +// Features Features `json:"features"` +// Icon string `json:"icon"` +// Name string `json:"name"` +// Repository string `json:"repository"` +// SSHURL string `json:"ssh_url"` +// Versions RecipeVersions `json:"versions"` +// Website string `json:"website"` +// } + +// type RecipeCatalogue map[Name]RecipeMeta \ No newline at end of file diff --git a/cli/new.go b/cli/new.go new file mode 100644 index 0000000..e9b63fa --- /dev/null +++ b/cli/new.go @@ -0,0 +1,54 @@ +package cli +import ( + "log" + "os/exec" + "net/http" + "encoding/json" + "fmt" +) +func (h *abraHandler) handleNewApp(w http.ResponseWriter, r *http.Request, appName string) { + args := []string{"app", "new", appName, "-n"} + d := json.NewDecoder(r.Body) + d.DisallowUnknownFields() // catch unwanted fields + + // anonymous struct type: handy for one-time use + body := struct { + Domain *string `json:"domain"` + Server *string `json:"server"` + Chaos *bool `json:"chaos"` + Secrets *bool `json:"secrets"` + }{} + + err := d.Decode(&body) + if err != nil { + log.Printf("???\n") + // bad JSON or unrecognized json field + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if body.Domain == nil { + http.Error(w, "missing field 'domain' from JSON object", http.StatusBadRequest) + return + } + if body.Server == nil { + http.Error(w, "missing field 'server' from JSON object", http.StatusBadRequest) + return + } + args = append(args, fmt.Sprintf("--domain=%s", *body.Domain)) + args = append(args, fmt.Sprintf("--server=%s", *body.Server)) + if body.Chaos != nil && *body.Chaos == true { + args = append(args, "-C") + } + if body.Chaos != nil && *body.Secrets == true { + args = append(args, "--secrets") + } + log.Printf("%v", args) + cmd := exec.Command("abra", args...) + output, err := cmd.Output() + if err != nil { + log.Printf("Error: ", string(output)) + InternalServerErrorHandler(w, r) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/cli/status/events.go b/cli/status/events.go new file mode 100644 index 0000000..454a003 --- /dev/null +++ b/cli/status/events.go @@ -0,0 +1,300 @@ +package status +import ( + "context" + "os" + "io" + "log" + "fmt" + "strings" + "time" + "encoding/json" + + dockerClient "github.com/docker/docker/client" + containerTypes "github.com/docker/docker/api/types/container" + "github.com/docker/cli/cli/command/service/progress" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/docker/docker/api/types/filters" + + "coopcloud.tech/abra/pkg/ui" +) +type Event interface { + ServiceID() string +} + +type ProgressEvent struct { + Service ServiceState + Err error + Failed bool +} +func (e ProgressEvent) ServiceID() string { return e.Service.Id } + +type HealthEvent struct { + Service ServiceState + Err error + Health string +} +func (e HealthEvent) ServiceID() string { return e.Service.Id } + +type StatusEvent struct { + Service ServiceState + Err error + JsonMsg jsonmessage.JSONMessage +} +func (e StatusEvent) ServiceID() string { return e.Service.Id } + + +type ServiceState struct { + Name string `json:"name"` + Err error `json:"err"` + Id string `json:"id"` + Status string `json:"status"` + Retries int `json:"retries"` + Health string `json:"health"` + Rollback bool `json:"rollback"` + Failed bool `json:"failed"` +} + +type DeployState struct { + count int + total int + failed bool + quit bool +} + +type DeployMsg int +const ( + FailMsg DeployMsg = iota + CompleteMsg + QuitMsg +) + +func (ds DeployState) complete() bool { + return ds.count == ds.total +} + +func progressProducer(ctx context.Context, cl *dockerClient.Client, s ServiceState, w *io.PipeWriter, ch chan<- Event) { + go func() { + log.Printf("producing progress...") + err := progress.ServiceProgress(ctx, cl, s.Id, w) + log.Printf("got some service progress here") + if err != nil { + log.Printf("Error in service progress") + ch <- ProgressEvent{ + Service: s, + Failed: true, + Err: err, + } + } else { + ch <- ProgressEvent{ + Service: s, + } + } + }() +} + +func statusProducer(ctx context.Context, decoder *json.Decoder, s ServiceState, ch chan<- Event) { + go func() { + log.Printf("producing status...") + for { + var msg jsonmessage.JSONMessage + + if err := decoder.Decode(&msg); err != nil { + if err == io.EOF { + return + } + + ch <- StatusEvent{ + Service: s, + Err: err, + } + return + } + log.Printf("Maybe writing JSON message") + msg.Display(os.Stdout, false); + ch <- StatusEvent{ + Service: s, + JsonMsg: msg, + } + } + }() +} + +func healthProducer(ctx context.Context, cl *dockerClient.Client, s ServiceState, ch chan<- Event) { + go func() { + log.Printf("producing health...") + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + health := "" + + filters := filters.NewArgs() + filters.Add("name", fmt.Sprintf("^%s", s.Name)) + + containers, err := cl.ContainerList(ctx, containerTypes.ListOptions{Filters: filters}) + if err != nil { + ch <- HealthEvent{Service: s, Err: err} + continue + } + if len(containers) == 0 { + ch <- HealthEvent{Service: s} + continue + } + + containerState, err := cl.ContainerInspect(ctx, containers[0].ID) + if err != nil { + ch <- HealthEvent{Service: s, Err: err} + continue + } + + if containerState.State.Health != nil { + health = containerState.State.Health.Status + } + ch <- HealthEvent{ + Service: s, + Health: health, + } + } + } + }() +} + +func processEvent(ctx context.Context, events <- chan Event, info chan <- DeployMsg, s ServiceState) error { + for { + select { + case event := <- events: + if event.ServiceID() != s.Id { + continue + } + switch event := event.(type) { + case ProgressEvent: + if event.Err != nil { + s.Err = event.Err + log.Printf("Error in progress event: %s", s.Err) + } + if event.Failed { + info <- FailMsg + } + // print for debugging purposes + log.Printf("Service: %s is complete", event.Service.Id) + // end print + info <- CompleteMsg + + case StatusEvent: + if event.Err != nil { + s.Err = event.Err + log.Printf("Error in status event: %s", s.Err) + } + + // print for debugging purposes + b, err := json.MarshalIndent(event.JsonMsg, "", " ") + if err != nil { + log.Printf("Problem with json message: %s", err) + } + log.Printf(string(b)) + // end print + + if event.JsonMsg.ID == "rollback" { + log.Printf("Failed in rollback") + s.Failed = true + s.Rollback = true + } + + if event.JsonMsg.ID != "overall progress" { + newStatus := strings.ToLower(event.JsonMsg.Status) + currentStatus := s.Status + + if !strings.Contains(currentStatus, "starting") && + strings.Contains(newStatus, "starting") { + s.Retries += 1 + } + + if s.Rollback { + if event.JsonMsg.ID == "rollback" { + s.Status = newStatus + } + } else { + s.Status = newStatus + } + } + + case HealthEvent: + if event.Err != nil { + s.Err = event.Err + log.Printf("Error in health event: %s", s.Err) + } + h := "?" + if s.Health != "" { + h = s.Health + } + if event.Health != "" { + h = event.Health + } + s.Health = h + } + + case <- ctx.Done(): + log.Printf("context is done???") + return ctx.Err() + } + } +} +func processService(ctx context.Context, info chan <- DeployMsg, cl *dockerClient.Client, s ServiceState, decoder *json.Decoder, writer *io.PipeWriter) { + events := make (chan Event, 50) + progressProducer(ctx, cl, s, writer, events) + statusProducer(ctx, decoder, s, events) + healthProducer(ctx, cl, s, events) + + go processEvent(ctx, events, info, s) +} + +func WaitOnServices(pctx context.Context, cl *dockerClient.Client, services []ui.ServiceMeta, filters filters.Args) { + ctx, cancel := context.WithCancel(pctx) + defer cancel() + + ds := DeployState{ + count: 0, + total: len(services), + failed: false, + quit: false, + } + info := make (chan DeployMsg, 50) + for _, service := range services { + r, w := io.Pipe() + d := json.NewDecoder(r) + s := ServiceState{ + Name: service.Name, + Id: service.ID, + Retries: -1, + Health: "?", + } + log.Printf("Processing Service: %s", s.Id) + processService(ctx, info, cl, s, d, w) + } + for { + select { + case msg := <-info: + switch msg { + case FailMsg: + ds.failed = true + case CompleteMsg: + ds.count += 1 + if ds.complete() { + log.Printf("deploy completed") + cancel() + } + case QuitMsg: + ds.quit = true + cancel() + } + case <-ctx.Done(): + log.Printf("Context finished because: %s", ctx.Err()) + return + } + } +} \ No newline at end of file diff --git a/cli/status/ws.txt b/cli/status/ws.txt new file mode 100644 index 0000000..fd1541f --- /dev/null +++ b/cli/status/ws.txt @@ -0,0 +1,41 @@ +// package ui +// import ( +// "github.com/gorilla/websocket" +// "time" +// "encoding/json" +// "github.com/docker/docker/pkg/jsonmessage" + +// ) +// type WSMessage struct { +// MsgType string `json:"msgType"` +// Timestamp time.Time `json:"timestamp"` +// Payload interface{} `json:"payload"` +// } +// type DonePayload struct { +// Success bool `json:"success"` +// Failed bool `json:"failed"` +// TimedOut bool `json:"timed_out"` +// } + +// func makeWsEmit(conn *websocket.Conn) func([]byte) error { +// return func(b []byte) error { +// if conn == nil { +// return nil +// } +// // Send as a text message +// return conn.WriteMessage(websocket.TextMessage, b) +// } +// } +// func send(wsEmit func([]byte) error, msgType string, payload interface{}) { +// if wsEmit == nil { +// return +// } +// msg := WSMessage{ +// MsgType: msgType, +// Timestamp: time.Now().UTC(), +// Payload: payload, +// } + +// b, _ := json.Marshal(msg) +// _ = wsEmit(b) +// } \ No newline at end of file diff --git a/cli/undeploy.go b/cli/undeploy.go new file mode 100644 index 0000000..20ea8ce --- /dev/null +++ b/cli/undeploy.go @@ -0,0 +1,16 @@ +package cli +import ( + "log" + "os/exec" + "net/http" +) +func (h *abraHandler) handleUndeployApp(w http.ResponseWriter, r *http.Request, appName string) { + cmd := exec.Command("abra", "app", "undeploy", appName, "-n") + output, err := cmd.Output() + if err != nil { + log.Printf("Error: ", string(output)) + InternalServerErrorHandler(w, r) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/go.mod b/go.mod index be227c5..192e83a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.25.6 require ( coopcloud.tech/abra v0.0.0-20260305102834-9d401202b4fb github.com/charmbracelet/log v0.4.2 + github.com/docker/cli v28.4.0+incompatible github.com/docker/docker v28.5.2+incompatible + github.com/gorilla/websocket v1.4.0 ) require ( @@ -39,7 +41,6 @@ require ( github.com/cyphar/filepath-securejoin v0.5.0 // indirect github.com/decentral1se/passgen v1.0.1 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v28.4.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect diff --git a/go.sum b/go.sum index 12f1d1b..59d69fe 100644 --- a/go.sum +++ b/go.sum @@ -253,6 +253,7 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= diff --git a/internal/state.go b/internal/state.go index c9e7489..594b76a 100644 --- a/internal/state.go +++ b/internal/state.go @@ -14,7 +14,7 @@ var ( DontWaitConverge bool Dry bool Force bool - MachineReadable bool + Secrets bool Major bool Minor bool NoDomainChecks bool