refactor api/ into cli/ and rewrite logic

This commit is contained in:
hey
2026-04-07 12:53:01 -04:00
parent 131161b262
commit d088a4be7b
23 changed files with 1704 additions and 370 deletions

View File

@ -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
> 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 | |

View File

@ -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()
}

32
api/catalogue.go Normal file
View File

@ -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)
}

299
api/deploy.go Normal file
View File

@ -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,
}
}

153
api/list.go Normal file
View File

@ -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)
}

View File

@ -28,4 +28,23 @@ type ServerApps struct {
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"`
}

32
api/new.go Normal file
View File

@ -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)
}

66
api/undeploy.go Normal file
View File

@ -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)
}

33
api/wrappers.go Normal file
View File

@ -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()
}

4
app.go
View File

@ -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()
}

157
cli/api.go Normal file
View File

@ -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)
}

31
cli/catalogue.go Normal file
View File

@ -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)
}

101
cli/deploy.go Normal file
View File

@ -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)})
}

13
cli/error.go Normal file
View File

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

192
cli/list.go Normal file
View File

@ -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
}

65
cli/models.go Normal file
View File

@ -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

54
cli/new.go Normal file
View File

@ -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)
}

300
cli/status/events.go Normal file
View File

@ -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
}
}
}

41
cli/status/ws.txt Normal file
View File

@ -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)
// }

16
cli/undeploy.go Normal file
View File

@ -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)
}

3
go.mod
View File

@ -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

1
go.sum
View File

@ -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=

View File

@ -14,7 +14,7 @@ var (
DontWaitConverge bool
Dry bool
Force bool
MachineReadable bool
Secrets bool
Major bool
Minor bool
NoDomainChecks bool