diff --git a/api/app_management/openapi.yaml b/api/app_management/openapi.yaml index 37fecaf7..62b34f84 100644 --- a/api/app_management/openapi.yaml +++ b/api/app_management/openapi.yaml @@ -431,6 +431,26 @@ paths: "500": $ref: "#/components/responses/ResponseInternalServerError" + /compose/{id}/healthcheck: + get: + summary: Check if the compose app is running healthy + description: | + By default this method simply check if the `port_map` of the compose app returns `200 OK` status code. + + Custom health check procedure will be implemented in the future. + operationId: checkComposeAppHealthByID + tags: + - Compose methods + parameters: + - $ref: "#/components/parameters/ComposeAppID" + responses: + "200": + $ref: "#/components/responses/ComposeAppHealthCheckOK" + "404": + $ref: "#/components/responses/ResponseNotFound" + "503": + $ref: "#/components/responses/ResponseServiceUnavailable" + /container/{id}: patch: summary: Recreate the container app @@ -453,11 +473,15 @@ paths: /container/{id}/healthcheck: get: + deprecated: true summary: Check if the container app is running healthy description: | By default this method simply check if the WebUI port of the app returns `200 OK` status code. Custom health check procedure will be implemented in the future. + + > This method works for legacy apps running on CasaOS v0.4.3 and earlier. + > For compose app, use `GET /compose/{id}/healthcheck` instead. operationId: checkContainerHealthByID tags: - Container methods @@ -836,6 +860,15 @@ components: type: string example: + ComposeAppHealthCheckOK: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/BaseResponse" + example: + message: "pong" + ContainerHealthCheckOK: description: OK content: diff --git a/route/v2/compose_app.go b/route/v2/compose_app.go index 8822dccb..eedd2f29 100644 --- a/route/v2/compose_app.go +++ b/route/v2/compose_app.go @@ -471,6 +471,42 @@ func (a *AppManagement) ComposeAppContainers(ctx echo.Context, id codegen.Compos }) } +func (a *AppManagement) CheckComposeAppHealthByID(ctx echo.Context, id codegen.ComposeAppID) error { + if id == "" { + message := ErrComposeAppIDNotProvided.Error() + return ctx.JSON(http.StatusBadRequest, codegen.ResponseBadRequest{ + Message: &message, + }) + } + + composeApps, err := service.MyService.Compose().List(ctx.Request().Context()) + if err != nil { + message := err.Error() + return ctx.JSON(http.StatusInternalServerError, codegen.ResponseInternalServerError{Message: &message}) + } + + composeApp, ok := composeApps[id] + if !ok { + message := fmt.Sprintf("compose app `%s` not found", id) + return ctx.JSON(http.StatusNotFound, codegen.ResponseNotFound{Message: &message}) + } + + result, err := composeApp.HealthCheck() + if err != nil { + message := err.Error() + return ctx.JSON(http.StatusServiceUnavailable, codegen.ResponseServiceUnavailable{Message: &message}) + } + + if !result { + return ctx.JSON(http.StatusServiceUnavailable, codegen.ResponseServiceUnavailable{}) + } + + message := fmt.Sprintf("compose app `%s` passed the health check", id) + return ctx.JSON(http.StatusOK, codegen.ComposeAppHealthCheckOK{ + Message: &message, + }) +} + func YAMLfromRequest(ctx echo.Context) ([]byte, error) { var buf []byte diff --git a/service/compose_app.go b/service/compose_app.go index 5d0ba574..fac92155 100644 --- a/service/compose_app.go +++ b/service/compose_app.go @@ -5,9 +5,11 @@ import ( "context" "fmt" "io" + "net/http" "path/filepath" "strconv" "strings" + "time" "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" "github.com/IceWhaleTech/CasaOS-AppManagement/common" @@ -21,6 +23,7 @@ import ( composeCmd "github.com/docker/compose/v2/cmd/compose" "github.com/docker/compose/v2/cmd/formatter" "github.com/docker/compose/v2/pkg/api" + "github.com/go-resty/resty/v2" "github.com/samber/lo" "go.uber.org/zap" "gopkg.in/yaml.v3" @@ -693,6 +696,48 @@ func (a *ComposeApp) GetPortsInUse() (*codegen.ComposeAppValidationErrorsPortsIn return &codegen.ComposeAppValidationErrorsPortsInUse{PortsInUse: &portsInUse}, nil } +func (a *ComposeApp) HealthCheck() (bool, error) { + storeInfo, err := a.StoreInfo(false) + if err != nil { + return false, err + } + + scheme := "http" + if storeInfo.Scheme != nil { + scheme = string(*storeInfo.Scheme) + } + + hostname := common.Localhost + if storeInfo.Hostname != nil { + hostname = *storeInfo.Hostname + } + + url := fmt.Sprintf( + "%s://%s:%s/%s", + scheme, + hostname, + storeInfo.PortMap, + strings.TrimLeft(storeInfo.Index, "/"), + ) + + logger.Info("checking compose app health at the specified web port...", zap.String("name", a.Name), zap.Any("url", url)) + + client := resty.New() + client.SetTimeout(30 * time.Second) + client.SetHeader("Accept", "text/html") + response, err := client.R().Get(url) + if err != nil { + logger.Error("failed to check container health", zap.Error(err), zap.String("name", a.Name)) + return false, err + } + if response.StatusCode() == http.StatusOK || response.StatusCode() == http.StatusUnauthorized { + return true, nil + } + + logger.Error("compose app health check failed at the specified web port", zap.Any("name", a.Name), zap.Any("url", url), zap.String("status", fmt.Sprint(response.StatusCode()))) + return false, nil +} + func LoadComposeAppFromConfigFile(appID string, configFile string) (*ComposeApp, error) { options := composeCmd.ProjectOptions{ ProjectDir: filepath.Dir(configFile),