13 Commits

Author SHA1 Message Date
82269195ed docker: run with nomock branch 2026-05-15 13:09:33 -04:00
39b4e323dd dockerfile: combine front and back end 2026-05-15 12:41:49 -04:00
da0863fda7 Merge pull request 'update config working' (#1) from jjsfunhouse/coop-cloud-backend:main into main
Reviewed-on: toolshed/coop-cloud-backend#1
2026-05-08 16:05:47 +00:00
hey
fbf5b90cf8 update config working 2026-05-08 11:50:46 -04:00
hey
fb72298f71 update instructions again 2026-04-18 17:42:57 -04:00
hey
d753bf873d update dev instructions 2026-04-18 17:40:34 -04:00
hey
3fbb283624 random logs and fix header issues 2026-04-18 15:10:12 -04:00
hey
8e55df779e update readme 2026-04-13 19:55:04 -04:00
hey
23775a9221 add service information and log streams 2026-04-13 19:54:27 -04:00
hey
d088a4be7b refactor api/ into cli/ and rewrite logic 2026-04-07 12:53:01 -04:00
hey
131161b262 add chaos header and update README 2026-03-13 23:47:59 -04:00
cf55b7908f update docker 2026-03-11 18:48:53 -04:00
4f2880d0df update docker 2026-03-11 18:47:09 -04:00
33 changed files with 2241 additions and 404 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
dot_abra/
ssh_config
id_*
app
coop-cloud-backend

View File

@ -7,24 +7,22 @@ FROM node
RUN mkdir /home/node/wizard RUN mkdir /home/node/wizard
COPY --from=0 /backend/gobackend /home/node/wizard/gobackend COPY --from=0 /backend/gobackend /home/node/wizard/gobackend
COPY --parents web /home/node/wizard
WORKDIR /home/node/wizard/web
RUN npm install .
USER node USER node
RUN curl https://install.abra.coopcloud.tech | bash COPY ./command.sh /
ENV ABRA_BIN=/home/node/.local/bin/abra
RUN $ABRA_BIN recipe fetch -a
COPY ./start.sh /
COPY dot_abra /home/node/.abra/
COPY ssh_config /home/node/.ssh/config COPY ssh_config /home/node/.ssh/config
COPY id_ed25519_* /home/node/.ssh/ COPY id_ed25519_* /home/node/.ssh/
COPY dot_abra /home/node/.abra/
RUN curl https://install.abra.coopcloud.tech | bash
ENV ABRA_BIN=/home/node/.local/bin/abra
# RUN $ABRA_BIN recipe fetch -a
COPY --parents web /home/node/wizard
WORKDIR /home/node/wizard/web
USER root USER root
RUN npm install .
RUN chown -R node /home/node/ RUN chown -R node /home/node/
USER node USER node
EXPOSE 5173 3000 EXPOSE 5173 3000
# ENV VITE_API_URL=http://localhost:3000/api # ENV VITE_API_URL=http://localhost:3000/api
CMD [ "/start.sh" ] CMD [ "/command.sh" ]

View File

@ -2,13 +2,47 @@
A Go service that exposes RESTful API endpoints to manage Abra programmatically. A Go service that exposes RESTful API endpoints to manage Abra programmatically.
Integrates with https://git.coopcloud.tech/BornDeleuze/coop-cloud-front. Integrates with https://git.coopcloud.tech/BornDeleuze/coop-cloud-front.
## Getting started ## Starting the service with Docker
Build the container:
```bash
docker build -t abra-wizard:latest
```
- Edit the front-end application to turn off mock mode in `src/routes/Authenticated/App.tsx` and `src/routes/Authenticated/Apps.tsx`. Run the container:
```bash
docker run abra-wizard
```
## Getting started with development
- Clone the front end application `git clone https://git.coopcloud.tech/BornDeleuze/coop-cloud-front.git`
- Checkout the `dev-nomock` branch `cd coop-cloud-front && git checkout dev-nomock`
- Launch the front-end application `npm run dev` - Launch the front-end application `npm run dev`
- Start this Go app `go run .` - Start this Go app `go run .`
- Navigate to the React App (http://localhost:5173) - Navigate to the React App (http://localhost:5173)
> [!WARNING] > [!WARNING]
> This is an extremely early prototype, only viewing apps is supported > 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 | In Progress | |
| Modify App config | TODO | |
| Manage updates and rollbacks | TODO | |
| Manage secrets and Abra commands | In Progress | |

View File

@ -1,439 +1,143 @@
package api package api
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"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"
"net/http" "net/http"
"github.com/gorilla/websocket"
composetypes "github.com/docker/cli/cli/compose/types"
"coop-cloud-backend/internal" "coop-cloud-backend/internal"
) )
// Upgrader is used to upgrade HTTP connections to WebSocket connections.
type abraHandler struct { var upgrader = websocket.Upgrader{
mux *http.ServeMux 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 { func newAbraHandler() *abraHandler {
h := &abraHandler{ h := &abraHandler{
mux: http.NewServeMux(), mux: http.NewServeMux(),
} }
h.mux.HandleFunc("/api/abra/apps", func(w http.ResponseWriter, r *http.Request) { 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-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
h.handleListApps(w, r) h.handleListApps(w, r)
default: default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
} }
}) })
h.mux.HandleFunc("/api/abra/apps/{serverId}/{appId}/deploy", func(w http.ResponseWriter, r *http.Request) { 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-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
if r.Header.Get("Chaos") == "true"{
switch r.Method { internal.Chaos = true
} else {
internal.Chaos = false
}
switch r.Method{
case http.MethodPost: case http.MethodPost:
h.handleDeployApp(w, r, r.PathValue("appId"), r.PathValue("serverId")) 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: default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
} }
}) })
h.mux.HandleFunc("/api/abra/servers", func(w http.ResponseWriter, r *http.Request) { 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-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
h.handleListServers(w, r) h.handleListServers(w, r)
default: default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed) 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 return h
} }
func StartAPI() { 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() h := newAbraHandler()
log.Println("Server started on port 3000") fmt.Println("Server started on port 3000")
log.Fatal(http.ListenAndServe(":3000", h)) http.ListenAndServe(":3000", h)
} }
func (h *abraHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *abraHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Pre-processing: logging // Pre-processing: logging
log.Printf("Incoming %s request: %s\n", r.Method, r.URL.Path) 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 // Delegate to internal mux
h.mux.ServeHTTP(w, r) 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, serverId string) {
log.Printf("App Id: %s | Server Id: %s", appName, serverId)
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.Fatalf("%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")
log.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"` UnversionedCount int `json:"unversionedCount"`
LatestCount int `json:"latestCount"` LatestCount int `json:"latestCount"`
UpgradeCount int `json:"upgradeCount"` 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()
}

12
app.go
View File

@ -17,7 +17,7 @@ import (
// "github.com/spf13/cobra" // "github.com/spf13/cobra"
// "coopcloud.tech/abra/pkg/log" // "coopcloud.tech/abra/pkg/log"
"coop-cloud-backend/api" "coop-cloud-backend/cli"
) )
// getEnv reads env variables from docker services. // getEnv reads env variables from docker services.
@ -49,7 +49,7 @@ import (
// return envMap, nil // return envMap, nil
// } // }
func printApp(app appPkg.App) error { func printApp(app appPkg.App) error {
log.Printf("Name: %s | Domain: %s | Server: %s | Path: %s", fmt.Printf("Name: %s | Domain: %s | Server: %s | Path: %s",
app.Name, app.Domain, app.Server, app.Path) app.Name, app.Domain, app.Server, app.Path)
return nil return nil
} }
@ -64,13 +64,13 @@ func connectToContainer(app appPkg.App, serviceName string) {
filters := filters.NewArgs() filters := filters.NewArgs()
filters.Add("name", stackAndServiceName) filters.Add("name", stackAndServiceName)
log.Printf("HI?") fmt.Printf("HI?")
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, false) targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, false)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
log.Printf("Container ID: %s", targetContainer.ID) fmt.Printf("Container ID: %s", targetContainer.ID)
} }
func main() { func main() {
appNames, err := appPkg.GetAppNames() appNames, err := appPkg.GetAppNames()
@ -79,8 +79,8 @@ func main() {
} }
for _, appName := range appNames { for _, appName := range appNames {
val := fmt.Sprintf("app , %v", appName) val := fmt.Sprintf("app , %v", appName)
log.Println(val) fmt.Println(val)
} }
api.StartAPI() cli.StartAPI()
} }

180
cli/api.go Normal file
View File

@ -0,0 +1,180 @@
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("/", func(w http.ResponseWriter, r *http.Request) {
log.Printf("API Path not found")
http.Error(w, "API Path not found", http.StatusMethodNotAllowed)
})
h.mux.HandleFunc("/api/apps", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleListApps(w, r)
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/deploy", func(w http.ResponseWriter, r *http.Request) {
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"))
case http.MethodGet:
h.getDeployLogs(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/stop", func(w http.ResponseWriter, r *http.Request) {
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/apps/{appId}/remove", func(w http.ResponseWriter, r *http.Request) {
switch r.Method{
case http.MethodPost:
h.handleRemoveApp(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/servers", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleListServers(w, r)
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/catalogue", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleListCatalogue(w, r)
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/new", func(w http.ResponseWriter, r *http.Request) {
switch r.Method{
case http.MethodPost:
h.handleNewApp(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/config", func(w http.ResponseWriter, r *http.Request) {
switch r.Method{
case http.MethodPost:
h.handlePostConfig(w, r, r.PathValue("appId"))
case http.MethodGet:
h.handleGetConfig(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/secret", func(w http.ResponseWriter, r *http.Request) {
switch r.Method{
case http.MethodPost:
h.handleInsertAppSecret(w, r, r.PathValue("appId"))
case http.MethodGet:
h.handleGetAppSecrets(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/services", func(w http.ResponseWriter, r *http.Request) {
switch r.Method{
case http.MethodGet:
h.handleGetAppServices(w, r, r.PathValue("appId"))
default:
http.Error(w, "Method not implemented", http.StatusMethodNotAllowed)
}
})
h.mux.HandleFunc("/api/apps/{appId}/{serviceId}/logs", func(w http.ResponseWriter, r *http.Request) {
switch r.Method{
case http.MethodGet:
h.handleGetLogs(w, r, r.PathValue("appId"), r.PathValue("serviceId"))
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, X-Requested-With")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}

75
cli/config.go Normal file
View File

@ -0,0 +1,75 @@
package cli
import (
"log"
"io/ioutil"
"os"
"net/http"
"encoding/json"
appPkg "coopcloud.tech/abra/pkg/app"
)
type FileResponse struct {
Content string `json:"content"`
}
func (h *abraHandler) handleGetConfig(w http.ResponseWriter, r *http.Request, appName string) {
files, err := appPkg.LoadAppFiles("")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
appFile, exists := files[appName]
if !exists {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("path: %s | server: %s", appFile.Path, appFile.Server)
log.Printf("Ending...")
// TODO: sanitize
file, err := ioutil.ReadFile(appFile.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := FileResponse{Content: string(file)}
jsonBytes, err := json.Marshal(resp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
func (h *abraHandler) handlePostConfig(w http.ResponseWriter, r *http.Request, appName string) {
files, err := appPkg.LoadAppFiles("")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
appFile, exists := files[appName]
if !exists {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields() // catch unwanted fields
var config FileResponse
err = d.Decode(&config)
if err != nil {
log.Printf("err: %s", err);
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
err = os.WriteFile(appFile.Path, []byte(config.Content), 0644)
if err != nil {
http.Error(w, "Failed to write file", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

139
cli/deploy.go Normal file
View File

@ -0,0 +1,139 @@
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
}
log.Printf("Finishing app deploy!")
w.WriteHeader(http.StatusOK)
}
func (h *abraHandler) getDeployLogs(w http.ResponseWriter, r *http.Request, appName string) {
log.Printf("Get deploy logs!")
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,
})
}
ctx := r.Context()
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
log.Printf("Waiting on service...")
stream := make (chan status.StreamEvent, 50)
go status.WaitOnServices(ctx, cl, serviceIDs, f, stream)
for {
select{
case <- ctx.Done():
log.Printf("deploy cancelled or done")
return
case msg, ok := <- stream:
if !ok {
return
}
test, err := json.MarshalIndent(msg, "", " ")
fmt.Printf(string(test), err);
b, err := json.Marshal(msg)
if err != nil {
// TODO: send error through onerror handler
log.Printf("error?: %s", err)
continue
}
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
}
}
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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
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
}

86
cli/logs.go Normal file
View File

@ -0,0 +1,86 @@
package cli
import (
"context"
"log"
"fmt"
"net/http"
appPkg "coopcloud.tech/abra/pkg/app"
"coop-cloud-backend/cli/status"
"coopcloud.tech/abra/pkg/client"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types"
)
func (h *abraHandler) handleGetLogs(w http.ResponseWriter, r *http.Request, appName string, serviceName string) {
app, err := appPkg.Get(appName)
if err != nil {
log.Printf("error: %s", err)
return
}
stackName := app.StackName()
if err := app.Recipe.EnsureExists(); err != nil {
log.Fatal(err)
return
}
cl, err := client.New(app.Server)
if err != nil {
log.Fatal(err)
return
}
deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
if err != nil {
log.Fatal(err)
return
}
if !deployMeta.IsDeployed {
log.Printf(fmt.Sprintf("%s is not deployed?", app.Name))
return
}
serviceNames := []string{serviceName}
f, err := app.Filters(true, false, serviceNames...)
if err != nil {
log.Fatal(err)
}
ctx := r.Context()
services, err := cl.ServiceList(
context.Background(),
types.ServiceListOptions{Filters: f},
)
if err != nil {
log.Fatal(err)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
logCh := make (chan string, 50)
go status.StreamLogs(ctx, cl, services, logCh)
for {
select{
case <- ctx.Done():
log.Printf("log streaming done")
return
case line, ok := <- logCh:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", line)
flusher.Flush()
}
}
}

82
cli/models.go Normal file
View File

@ -0,0 +1,82 @@
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 AppSecret struct {
Name string `json:"name"`
Version string `json:"version"`
Value string `json:"value"`
}
type AbraAppService struct {
Service string `json:"service"`
Chaos bool `json:"chaos"`
Created string `json:"created"`
Image string `json:"image"`
Ports string `json:"ports"`
State string `json:"state"`
Status string `json:"status"`
Version string `json:"version"`
}
// 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

115
cli/new.go Normal file
View File

@ -0,0 +1,115 @@
package cli
import (
"log"
"os/exec"
"net/http"
"encoding/json"
"fmt"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/secret"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/client"
)
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 {
// 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")
}
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
}
var fs []AppSecret
if body.Secrets != nil && *body.Secrets == true {
appSecrets, err := createSecrets(appName, *body.Domain, *body.Server)
if err != nil {
log.Printf("Error creating secrets: %s", err)
InternalServerErrorHandler(w, r)
return
}
for k, v := range appSecrets {
fs = append(fs, AppSecret{
Name: k,
Value: v,
Version: "v1",
})
}
}
jsonBytes, err := json.Marshal(fs)
if err != nil {
log.Printf("JSON conversion failed: %s\n", err)
InternalServerErrorHandler(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}
func createSecrets(appName string, domain string, server string) (map[string]string, error) {
cl, err := client.New(server)
if err != nil {
return nil, err
}
recipe := recipe.Get(appName)
sampleEnv, err := recipe.SampleEnv()
if err != nil {
return nil, err
}
composeFiles, err := recipe.GetComposeFiles(sampleEnv)
if err != nil {
return nil, err
}
//sanitisedAppName := appPkg.SanitiseAppName(domain)
secretsConfig, err := secret.ReadSecretsConfig(
recipe.SampleEnvPath,
composeFiles,
appPkg.StackName(domain),
)
if err != nil {
return nil, err
}
secrets, err := secret.GenerateSecrets(cl, secretsConfig, server)
if err != nil {
return nil, err
}
return secrets, nil
}

48
cli/ping.go Normal file
View File

@ -0,0 +1,48 @@
package cli
import (
"os/exec"
"log"
"sort"
"net/http"
"encoding/json"
)
func (h *abraHandler) handleGetAppServices (w http.ResponseWriter, r *http.Request, appName string) {
var tmpMsg map[string]map[string]string
cmd := exec.Command("abra", "app", "ps", appName, "-m")
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Error: ", err)
InternalServerErrorHandler(w, r)
return
}
if err = json.Unmarshal(output, &tmpMsg); err != nil {
log.Printf("Error: ", err)
InternalServerErrorHandler(w, r)
return
}
var services []AbraAppService
for _,v := range tmpMsg {
services = append(services, AbraAppService{
Service: v["service"],
Chaos: v["chaos"] == "true",
Created: v["created"],
Image: v["image"],
Ports: v["ports"],
State: v["state"],
Status: v["status"],
Version: v["version"],
})
}
sort.Slice(services, func(i, j int) bool {
return services[i].Service < services[j].Service
})
jsonBytes, err := json.Marshal(services)
if err != nil {
log.Printf("JSON conversion failed: %s\n", err)
InternalServerErrorHandler(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
}

16
cli/remove.go Normal file
View File

@ -0,0 +1,16 @@
package cli
import (
"log"
"os/exec"
"net/http"
)
func (h *abraHandler) handleRemoveApp(w http.ResponseWriter, r *http.Request, appName string) {
cmd := exec.Command("abra", "app", "remove", appName, "-n")
output, err := cmd.Output()
if err != nil {
log.Printf("Error: ", string(output))
InternalServerErrorHandler(w, r)
return
}
w.WriteHeader(http.StatusOK)
}

62
cli/secret.go Normal file
View File

@ -0,0 +1,62 @@
package cli
import (
"log"
"os/exec"
"encoding/json"
"net/http"
)
func (h *abraHandler) handleGetAppSecrets(w http.ResponseWriter, r *http.Request, appName string) {
cmd := exec.Command("abra", "app", "secret", "list", appName, "-m")
secrets, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Error: ", err)
InternalServerErrorHandler(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(secrets)
}
func (h *abraHandler) handleInsertAppSecret(w http.ResponseWriter, r *http.Request, appName string) {
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields() // catch unwanted fields
// anonymous struct type: handy for one-time use
body := struct {
Name *string `json:"name"`
Version *string `json:"version"`
Value *string `json:"value"`
}{}
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.Name == nil {
http.Error(w, "missing field 'name' from JSON object", http.StatusBadRequest)
return
}
if body.Version == nil {
http.Error(w, "missing field 'version' from JSON object", http.StatusBadRequest)
return
}
if body.Value == nil {
http.Error(w, "missing field 'value' from JSON object", http.StatusBadRequest)
return
}
log.Printf("%s %s %s", *body.Name, *body.Version, *body.Value)
cmd := exec.Command("abra", "app", "secret", "insert", appName, *body.Name, *body.Version, *body.Value)
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Error: ", string(output))
InternalServerErrorHandler(w, r)
return
}
w.WriteHeader(http.StatusOK)
}

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

@ -0,0 +1,305 @@
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 StreamEvent struct {
Type string `json:"type"` // "service" | "done"
Data interface{} `json:"data"`
}
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 `json:"count"`
Total int `json:"total"`
Failed bool `json:"failed"`
Quit bool `json:"quit"`
}
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, stream chan <- StreamEvent) 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
}
stream <- StreamEvent{Type: "service", Data: s}
case <- ctx.Done():
return ctx.Err()
}
}
}
func processService(ctx context.Context, info chan <- DeployMsg, cl *dockerClient.Client, s ServiceState, decoder *json.Decoder, writer *io.PipeWriter, stream chan <- StreamEvent) {
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, stream)
}
func WaitOnServices(pctx context.Context, cl *dockerClient.Client, services []ui.ServiceMeta, filters filters.Args, stream chan <- StreamEvent) {
ctx, cancel := context.WithCancel(pctx)
defer cancel()
log.Printf("What???")
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, stream)
}
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("%v", ds)
stream <- StreamEvent{Type: "done", Data: ds}
log.Printf("Context finished because: %s", ctx.Err())
return
}
}
}

57
cli/status/logs.go Normal file
View File

@ -0,0 +1,57 @@
package status
import (
"bufio"
"context"
"fmt"
"sync"
"github.com/docker/docker/api/types/swarm"
dockerClient "github.com/docker/docker/client"
containerTypes "github.com/docker/docker/api/types/container"
)
func StreamLogs(
ctx context.Context,
cl *dockerClient.Client,
services []swarm.Service,
logCh chan <- string,
) error {
waitCh := make(chan struct{})
var wg sync.WaitGroup
for _, service := range services {
wg.Add(1)
go func(serviceID string) {
defer wg.Done()
tail := "50"
logs, err := cl.ServiceLogs(ctx, serviceID, containerTypes.LogsOptions{
ShowStderr: true,
ShowStdout: true,
Until: "",
Timestamps: true,
Follow: true,
Tail: tail,
Details: false,
})
if err == nil {
buf := bufio.NewScanner(logs)
for buf.Scan() {
select {
case <- ctx.Done():
return
default:
line := fmt.Sprintf("%s: %s", service.Spec.Name, buf.Text())
logCh <- line
}
}
logs.Close()
return
}
}(service.ID)
}
wg.Wait()
close(waitCh)
close(logCh)
return nil
}

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

View File

@ -1,5 +1,7 @@
#!/bin/bash #!/bin/bash
export PATH=/home/node/.local/bin/:$PATH
/home/node/wizard/gobackend & /home/node/wizard/gobackend &
npm run dev -- --host npm run dev -- --host

3
go.mod
View File

@ -5,7 +5,9 @@ go 1.25.6
require ( require (
coopcloud.tech/abra v0.0.0-20260305102834-9d401202b4fb coopcloud.tech/abra v0.0.0-20260305102834-9d401202b4fb
github.com/charmbracelet/log v0.4.2 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/docker/docker v28.5.2+incompatible
github.com/gorilla/websocket v1.4.0
) )
require ( require (
@ -39,7 +41,6 @@ require (
github.com/cyphar/filepath-securejoin v0.5.0 // indirect github.com/cyphar/filepath-securejoin v0.5.0 // indirect
github.com/decentral1se/passgen v1.0.1 // indirect github.com/decentral1se/passgen v1.0.1 // indirect
github.com/distribution/reference v0.6.0 // 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/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // 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.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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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/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-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= 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 DontWaitConverge bool
Dry bool Dry bool
Force bool Force bool
MachineReadable bool Secrets bool
Major bool Major bool
Minor bool Minor bool
NoDomainChecks bool NoDomainChecks bool

2
web

Submodule web updated: 08510b136e...e09aa9a1ca