feat(edge/stacks): increase status transparency [EE-5554] (#9094)
parent
db61fb149b
commit
0bcb57568c
|
@ -11,7 +11,7 @@ storybook-static
|
|||
*.DS_Store
|
||||
|
||||
.eslintcache
|
||||
__debug_bin
|
||||
__debug_bin*
|
||||
|
||||
api/docs
|
||||
.idea
|
||||
|
|
|
@ -78,7 +78,6 @@ type (
|
|||
EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error)
|
||||
EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
|
||||
Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error
|
||||
// Deprecated: Use UpdateEdgeStackFunc instead.
|
||||
UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error
|
||||
UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
DeleteEdgeStack(ID portainer.EdgeStackID) error
|
||||
|
|
|
@ -2,8 +2,9 @@ package migrator
|
|||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/chisel/crypto"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
@ -73,3 +74,77 @@ func (m *Migrator) convertSeedToPrivateKeyForDB100() error {
|
|||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Migrator) updateEdgeStackStatusForDB100() error {
|
||||
log.Info().Msg("update edge stack status to have deployment steps")
|
||||
|
||||
edgeStacks, err := m.edgeStackService.EdgeStacks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, edgeStack := range edgeStacks {
|
||||
|
||||
for environmentID, environmentStatus := range edgeStack.Status {
|
||||
// skip if status is already updated
|
||||
if len(environmentStatus.Status) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
statusArray := []portainer.EdgeStackDeploymentStatus{}
|
||||
if environmentStatus.Details.Pending {
|
||||
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
|
||||
Type: portainer.EdgeStackStatusPending,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
if environmentStatus.Details.Acknowledged {
|
||||
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
|
||||
Type: portainer.EdgeStackStatusAcknowledged,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
if environmentStatus.Details.Error {
|
||||
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
|
||||
Type: portainer.EdgeStackStatusError,
|
||||
Error: environmentStatus.Error,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
if environmentStatus.Details.Ok {
|
||||
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
|
||||
Type: portainer.EdgeStackStatusRunning,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
if environmentStatus.Details.ImagesPulled {
|
||||
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
|
||||
Type: portainer.EdgeStackStatusImagesPulled,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
if environmentStatus.Details.Remove {
|
||||
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
|
||||
Type: portainer.EdgeStackStatusRemoving,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
environmentStatus.Status = statusArray
|
||||
|
||||
edgeStack.Status[environmentID] = environmentStatus
|
||||
}
|
||||
|
||||
err = m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -78,13 +78,13 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
|
|||
switch status.Type {
|
||||
case portainer.EdgeStackStatusPending:
|
||||
status.Details.Pending = true
|
||||
case portainer.EdgeStackStatusOk:
|
||||
case portainer.EdgeStackStatusDeploymentReceived:
|
||||
status.Details.Ok = true
|
||||
case portainer.EdgeStackStatusError:
|
||||
status.Details.Error = true
|
||||
case portainer.EdgeStackStatusAcknowledged:
|
||||
status.Details.Acknowledged = true
|
||||
case portainer.EdgeStackStatusRemove:
|
||||
case portainer.EdgeStackStatusRemoved:
|
||||
status.Details.Remove = true
|
||||
case portainer.EdgeStackStatusRemoteUpdateSuccess:
|
||||
status.Details.RemoteUpdateSuccess = true
|
||||
|
|
|
@ -215,6 +215,7 @@ func (m *Migrator) initMigrations() {
|
|||
m.addMigrations("2.19",
|
||||
m.convertSeedToPrivateKeyForDB100,
|
||||
m.migrateDockerDesktopExtentionSetting,
|
||||
m.updateEdgeStackStatusForDB100,
|
||||
)
|
||||
|
||||
// Add new migrations below...
|
||||
|
|
|
@ -944,6 +944,6 @@
|
|||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":2,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ package edgestacks
|
|||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
|
@ -25,6 +26,7 @@ import (
|
|||
// @failure 400
|
||||
// @failure 404
|
||||
// @failure 403
|
||||
// @deprecated
|
||||
// @router /edge_stacks/{id}/status/{environmentId} [delete]
|
||||
func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
|
@ -69,7 +71,17 @@ func (handler *Handler) deleteEdgeStackStatus(tx dataservices.DataStoreTx, stack
|
|||
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
delete(stack.Status, endpoint.ID)
|
||||
environmentStatus, ok := stack.Status[endpoint.ID]
|
||||
if !ok {
|
||||
environmentStatus = portainer.EdgeStackStatus{}
|
||||
}
|
||||
|
||||
environmentStatus.Status = append(environmentStatus.Status, portainer.EdgeStackDeploymentStatus{
|
||||
Time: time.Now().Unix(),
|
||||
Type: portainer.EdgeStackStatusRemoved,
|
||||
})
|
||||
|
||||
stack.Status[endpoint.ID] = environmentStatus
|
||||
|
||||
err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
||||
if err != nil {
|
||||
|
|
|
@ -3,21 +3,24 @@ package edgestacks
|
|||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
)
|
||||
|
||||
type updateStatusPayload struct {
|
||||
Error string
|
||||
Status *portainer.EdgeStackStatusType
|
||||
EndpointID portainer.EndpointID
|
||||
Time int64
|
||||
}
|
||||
|
||||
func (payload *updateStatusPayload) Validate(r *http.Request) error {
|
||||
|
@ -33,6 +36,10 @@ func (payload *updateStatusPayload) Validate(r *http.Request) error {
|
|||
return errors.New("error message is mandatory when status is error")
|
||||
}
|
||||
|
||||
if payload.Time == 0 {
|
||||
payload.Time = time.Now().Unix()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -43,6 +50,7 @@ func (payload *updateStatusPayload) Validate(r *http.Request) error {
|
|||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "EdgeStack Id"
|
||||
// @param body body updateStatusPayload true "EdgeStack status payload"
|
||||
// @success 200 {object} portainer.EdgeStack
|
||||
// @failure 500
|
||||
// @failure 400
|
||||
|
@ -84,6 +92,21 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
|||
}
|
||||
|
||||
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
// skip error because agent tries to report on deleted stack
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Int("stackID", int(stackID)).
|
||||
Int("status", int(*payload.Status)).
|
||||
Msg("Unable to find a stack inside the database, skipping error")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint, err := tx.Endpoint().Endpoint(payload.EndpointID)
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(err, "Unable to find an environment with the specified identifier inside the database")
|
||||
|
@ -94,67 +117,50 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
|
|||
return nil, httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
status := *payload.Status
|
||||
|
||||
log.Debug().
|
||||
Int("stackID", int(stackID)).
|
||||
Int("status", int(status)).
|
||||
Msg("Updating stack status")
|
||||
|
||||
deploymentStatus := portainer.EdgeStackDeploymentStatus{
|
||||
Type: status,
|
||||
Error: payload.Error,
|
||||
Time: payload.Time,
|
||||
}
|
||||
|
||||
if featureflags.IsEnabled(portainer.FeatureNoTx) {
|
||||
err = tx.EdgeStack().UpdateEdgeStackFunc(portainer.EdgeStackID(stackID), func(edgeStack *portainer.EdgeStack) {
|
||||
details := edgeStack.Status[payload.EndpointID].Details
|
||||
details.Pending = false
|
||||
|
||||
switch *payload.Status {
|
||||
case portainer.EdgeStackStatusOk:
|
||||
details.Ok = true
|
||||
case portainer.EdgeStackStatusError:
|
||||
details.Error = true
|
||||
case portainer.EdgeStackStatusAcknowledged:
|
||||
details.Acknowledged = true
|
||||
case portainer.EdgeStackStatusRemove:
|
||||
details.Remove = true
|
||||
case portainer.EdgeStackStatusImagesPulled:
|
||||
details.ImagesPulled = true
|
||||
}
|
||||
|
||||
edgeStack.Status[payload.EndpointID] = portainer.EdgeStackStatus{
|
||||
Details: details,
|
||||
Error: payload.Error,
|
||||
EndpointID: payload.EndpointID,
|
||||
}
|
||||
err = tx.EdgeStack().UpdateEdgeStackFunc(stackID, func(edgeStack *portainer.EdgeStack) {
|
||||
updateEnvStatus(payload.EndpointID, edgeStack, deploymentStatus)
|
||||
|
||||
stack = edgeStack
|
||||
})
|
||||
} else {
|
||||
stack, err = tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
details := stack.Status[payload.EndpointID].Details
|
||||
details.Pending = false
|
||||
|
||||
switch *payload.Status {
|
||||
case portainer.EdgeStackStatusOk:
|
||||
details.Ok = true
|
||||
case portainer.EdgeStackStatusError:
|
||||
details.Error = true
|
||||
case portainer.EdgeStackStatusAcknowledged:
|
||||
details.Acknowledged = true
|
||||
case portainer.EdgeStackStatusRemove:
|
||||
details.Remove = true
|
||||
case portainer.EdgeStackStatusImagesPulled:
|
||||
details.ImagesPulled = true
|
||||
}
|
||||
|
||||
stack.Status[payload.EndpointID] = portainer.EdgeStackStatus{
|
||||
Details: details,
|
||||
Error: payload.Error,
|
||||
EndpointID: payload.EndpointID,
|
||||
return nil, handler.handlerDBErr(err, "Unable to persist the stack changes inside the database")
|
||||
}
|
||||
} else {
|
||||
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
|
||||
|
||||
err = tx.EdgeStack().UpdateEdgeStack(stackID, stack)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(err, "Unable to persist the stack changes inside the database")
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(err, "Unable to persist the stack changes inside the database")
|
||||
}
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) {
|
||||
environmentStatus, ok := stack.Status[environmentId]
|
||||
if !ok {
|
||||
environmentStatus = portainer.EdgeStackStatus{
|
||||
EndpointID: environmentId,
|
||||
Status: []portainer.EdgeStackDeploymentStatus{},
|
||||
}
|
||||
}
|
||||
|
||||
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
|
||||
|
||||
stack.Status[environmentId] = environmentStatus
|
||||
}
|
||||
|
|
|
@ -59,23 +59,31 @@ func TestUpdateStatusAndInspect(t *testing.T) {
|
|||
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
data := portainer.EdgeStack{}
|
||||
err = json.NewDecoder(rec.Body).Decode(&data)
|
||||
updatedStack := portainer.EdgeStack{}
|
||||
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
|
||||
if err != nil {
|
||||
t.Fatal("error decoding response:", err)
|
||||
}
|
||||
|
||||
if !data.Status[endpoint.ID].Details.Error {
|
||||
t.Fatalf("expected EdgeStackStatusType %d, found %t", payload.Status, data.Status[endpoint.ID].Details.Error)
|
||||
endpointStatus, ok := updatedStack.Status[payload.EndpointID]
|
||||
if !ok {
|
||||
t.Fatal("Missing status")
|
||||
}
|
||||
|
||||
if data.Status[endpoint.ID].Error != payload.Error {
|
||||
t.Fatalf("expected EdgeStackStatusError %s, found %s", payload.Error, data.Status[endpoint.ID].Error)
|
||||
lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1]
|
||||
|
||||
if len(endpointStatus.Status) == len(edgeStack.Status[payload.EndpointID].Status) {
|
||||
t.Fatal("expected status array to be updated")
|
||||
}
|
||||
|
||||
if data.Status[endpoint.ID].EndpointID != payload.EndpointID {
|
||||
t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, data.Status[endpoint.ID].EndpointID)
|
||||
if lastStatus.Type != *payload.Status {
|
||||
t.Fatalf("expected EdgeStackStatusType %d, found %d", *payload.Status, lastStatus.Type)
|
||||
}
|
||||
|
||||
if endpointStatus.EndpointID != portainer.EndpointID(payload.EndpointID) {
|
||||
t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID)
|
||||
}
|
||||
|
||||
}
|
||||
func TestUpdateStatusWithInvalidPayload(t *testing.T) {
|
||||
handler, _ := setupHandler(t)
|
||||
|
@ -85,7 +93,7 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
|
|||
|
||||
// Update edge stack status
|
||||
statusError := portainer.EdgeStackStatusError
|
||||
statusOk := portainer.EdgeStackStatusOk
|
||||
statusOk := portainer.EdgeStackStatusDeploymentReceived
|
||||
cases := []struct {
|
||||
Name string
|
||||
Payload updateStatusPayload
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package edgestacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
@ -41,20 +42,22 @@ func setupHandler(t *testing.T) (*Handler, string) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edgeStacksService := edgestacks.NewService(store)
|
||||
|
||||
handler := NewHandler(
|
||||
security.NewRequestBouncer(store, jwtService, apiKeyService),
|
||||
store,
|
||||
edgeStacksService,
|
||||
)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpDir, err := os.MkdirTemp(t.TempDir(), "portainer-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fs, err := filesystem.NewService(tmpDir, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := NewHandler(
|
||||
security.NewRequestBouncer(store, jwtService, apiKeyService),
|
||||
store,
|
||||
edgestacks.NewService(store),
|
||||
)
|
||||
|
||||
handler.FileService = fs
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
|
@ -116,11 +119,9 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
|
|||
|
||||
edgeStackID := portainer.EdgeStackID(14)
|
||||
edgeStack := portainer.EdgeStack{
|
||||
ID: edgeStackID,
|
||||
Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)),
|
||||
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{
|
||||
endpointID: {Details: portainer.EdgeStackStatusDetails{Ok: true}, Error: "", EndpointID: endpointID},
|
||||
},
|
||||
ID: edgeStackID,
|
||||
Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)),
|
||||
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{},
|
||||
CreationDate: time.Now().Unix(),
|
||||
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
|
||||
ProjectPath: "/project/path",
|
||||
|
|
|
@ -2,7 +2,7 @@ package edgestacks
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
|
@ -10,12 +10,9 @@ import (
|
|||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/set"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type updateEdgeStackPayload struct {
|
||||
|
@ -116,24 +113,6 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
|
|||
|
||||
}
|
||||
|
||||
entryPoint := stack.EntryPoint
|
||||
manifestPath := stack.ManifestPath
|
||||
deploymentType := stack.DeploymentType
|
||||
|
||||
if deploymentType != payload.DeploymentType {
|
||||
// deployment type was changed - need to delete the old file
|
||||
err = handler.FileService.RemoveDirectory(stack.ProjectPath)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to clear old files")
|
||||
}
|
||||
|
||||
entryPoint = ""
|
||||
manifestPath = ""
|
||||
deploymentType = payload.DeploymentType
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
|
||||
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType)
|
||||
if err != nil {
|
||||
return nil, httperror.BadRequest("unable to check for existence of non fitting environments: %w", err)
|
||||
|
@ -142,50 +121,20 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
|
|||
return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil)
|
||||
}
|
||||
|
||||
if payload.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
if entryPoint == "" {
|
||||
entryPoint = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
stack.NumDeployments = len(relatedEndpointIds)
|
||||
|
||||
_, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, []byte(payload.StackFileContent))
|
||||
stack.UseManifestNamespaces = payload.UseManifestNamespaces
|
||||
|
||||
stack.EdgeGroups = groupsIds
|
||||
|
||||
if payload.UpdateVersion {
|
||||
err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
|
||||
}
|
||||
|
||||
tempManifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, stack.ProjectPath, entryPoint, relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to convert and persist updated Kubernetes manifest file on disk", err)
|
||||
}
|
||||
|
||||
manifestPath = tempManifestPath
|
||||
}
|
||||
|
||||
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
if manifestPath == "" {
|
||||
manifestPath = filesystem.ManifestFileDefaultName
|
||||
}
|
||||
|
||||
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist updated Kubernetes manifest file on disk", err)
|
||||
return nil, httperror.InternalServerError("Unable to update stack version", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.EdgeStack().UpdateEdgeStackFunc(stack.ID, func(edgeStack *portainer.EdgeStack) {
|
||||
edgeStack.NumDeployments = len(relatedEndpointIds)
|
||||
if payload.UpdateVersion {
|
||||
edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
|
||||
edgeStack.Version++
|
||||
}
|
||||
|
||||
edgeStack.UseManifestNamespaces = payload.UseManifestNamespaces
|
||||
|
||||
edgeStack.DeploymentType = deploymentType
|
||||
edgeStack.EntryPoint = entryPoint
|
||||
edgeStack.ManifestPath = manifestPath
|
||||
|
||||
edgeStack.EdgeGroups = groupsIds
|
||||
})
|
||||
err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
|
||||
}
|
||||
|
@ -246,3 +195,26 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
|
|||
|
||||
return newRelatedEnvironmentIDs, endpointsToAdd, nil
|
||||
}
|
||||
|
||||
func newStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, relatedEnvironmentIds []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeStackStatus {
|
||||
newStatus := make(map[portainer.EndpointID]portainer.EdgeStackStatus)
|
||||
for _, endpointID := range relatedEnvironmentIds {
|
||||
newEnvStatus := portainer.EdgeStackStatus{}
|
||||
|
||||
oldEnvStatus, ok := oldStatus[endpointID]
|
||||
if ok {
|
||||
newEnvStatus = oldEnvStatus
|
||||
}
|
||||
|
||||
newEnvStatus.Status = []portainer.EdgeStackDeploymentStatus{
|
||||
{
|
||||
Time: time.Now().Unix(),
|
||||
Type: portainer.EdgeStackStatusPending,
|
||||
},
|
||||
}
|
||||
|
||||
newStatus[endpointID] = newEnvStatus
|
||||
}
|
||||
|
||||
return newStatus
|
||||
}
|
||||
|
|
|
@ -94,21 +94,21 @@ func TestUpdateAndInspect(t *testing.T) {
|
|||
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
data := portainer.EdgeStack{}
|
||||
err = json.NewDecoder(rec.Body).Decode(&data)
|
||||
updatedStack := portainer.EdgeStack{}
|
||||
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
|
||||
if err != nil {
|
||||
t.Fatal("error decoding response:", err)
|
||||
}
|
||||
|
||||
if payload.UpdateVersion && data.Version != edgeStack.Version+1 {
|
||||
t.Fatalf("expected EdgeStackID %d, found %d", edgeStack.Version, data.Version)
|
||||
if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 {
|
||||
t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1)
|
||||
}
|
||||
|
||||
if data.DeploymentType != payload.DeploymentType {
|
||||
t.Fatalf("expected DeploymentType %d, found %d", edgeStack.DeploymentType, data.DeploymentType)
|
||||
if updatedStack.DeploymentType != payload.DeploymentType {
|
||||
t.Fatalf("expected DeploymentType %d, found %d", edgeStack.DeploymentType, updatedStack.DeploymentType)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(data.EdgeGroups, payload.EdgeGroups) {
|
||||
if !reflect.DeepEqual(updatedStack.EdgeGroups, payload.EdgeGroups) {
|
||||
t.Fatalf("expected EdgeGroups to be equal")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
edgestackutils "github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (handler *Handler) updateStackVersion(stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error {
|
||||
|
||||
stack.Version = stack.Version + 1
|
||||
stack.Status = edgestackutils.NewStatus(stack.Status, relatedEnvironmentsIDs)
|
||||
|
||||
return handler.storeStackFile(stack, deploymentType, config)
|
||||
}
|
||||
|
||||
func (handler *Handler) storeStackFile(stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte) error {
|
||||
|
||||
if deploymentType != stack.DeploymentType {
|
||||
// deployment type was changed - need to delete all old files
|
||||
err := handler.FileService.RemoveDirectory(stack.ProjectPath)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to clear old files")
|
||||
}
|
||||
|
||||
stack.EntryPoint = ""
|
||||
stack.ManifestPath = ""
|
||||
stack.DeploymentType = deploymentType
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
entryPoint := ""
|
||||
if deploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
if stack.EntryPoint == "" {
|
||||
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
entryPoint = stack.EntryPoint
|
||||
}
|
||||
|
||||
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
if stack.ManifestPath == "" {
|
||||
stack.ManifestPath = filesystem.ManifestFileDefaultName
|
||||
}
|
||||
|
||||
entryPoint = stack.ManifestPath
|
||||
}
|
||||
|
||||
_, err := handler.FileService.StoreEdgeStackFileFromBytesByVersion(stackFolder, entryPoint, stack.Version, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -291,7 +291,7 @@ func TestEdgeStackStatus(t *testing.T) {
|
|||
ID: edgeStackID,
|
||||
Name: "test-edge-stack-17",
|
||||
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{
|
||||
endpointID: {Details: portainer.EdgeStackStatusDetails{Ok: true}, Error: "", EndpointID: endpoint.ID},
|
||||
endpointID: {},
|
||||
},
|
||||
CreationDate: time.Now().Unix(),
|
||||
EdgeGroups: []portainer.EdgeGroupID{1, 2},
|
||||
|
|
|
@ -17,18 +17,6 @@ import (
|
|||
"github.com/portainer/portainer/api/internal/unique"
|
||||
)
|
||||
|
||||
type EdgeStackStatusFilter string
|
||||
|
||||
const (
|
||||
statusFilterPending EdgeStackStatusFilter = "Pending"
|
||||
statusFilterOk EdgeStackStatusFilter = "Ok"
|
||||
statusFilterError EdgeStackStatusFilter = "Error"
|
||||
statusFilterAcknowledged EdgeStackStatusFilter = "Acknowledged"
|
||||
statusFilterRemove EdgeStackStatusFilter = "Remove"
|
||||
statusFilterRemoteUpdateSuccess EdgeStackStatusFilter = "RemoteUpdateSuccess"
|
||||
statusFilterImagesPulled EdgeStackStatusFilter = "ImagesPulled"
|
||||
)
|
||||
|
||||
type EnvironmentsQuery struct {
|
||||
search string
|
||||
types []portainer.EndpointType
|
||||
|
@ -45,7 +33,7 @@ type EnvironmentsQuery struct {
|
|||
agentVersions []string
|
||||
edgeCheckInPassedSeconds int
|
||||
edgeStackId portainer.EdgeStackID
|
||||
edgeStackStatus EdgeStackStatusFilter
|
||||
edgeStackStatus *portainer.EdgeStackStatusType
|
||||
}
|
||||
|
||||
func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
|
@ -99,7 +87,18 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
|||
|
||||
edgeStackId, _ := request.RetrieveNumericQueryParameter(r, "edgeStackId", true)
|
||||
|
||||
edgeStackStatus, _ := request.RetrieveQueryParameter(r, "edgeStackStatus", true)
|
||||
edgeStackStatusQuery, _ := request.RetrieveQueryParameter(r, "edgeStackStatus", true)
|
||||
var edgeStackStatus *portainer.EdgeStackStatusType
|
||||
if edgeStackStatusQuery != "" {
|
||||
edgeStackStatusNumber, err := strconv.Atoi(edgeStackStatusQuery)
|
||||
if err != nil ||
|
||||
edgeStackStatusNumber < 0 ||
|
||||
edgeStackStatusNumber > int(portainer.EdgeStackStatusRemoving) {
|
||||
return EnvironmentsQuery{}, errors.New("invalid edgeStackStatus parameter")
|
||||
}
|
||||
|
||||
edgeStackStatus = ptr(portainer.EdgeStackStatusType(edgeStackStatusNumber))
|
||||
}
|
||||
|
||||
return EnvironmentsQuery{
|
||||
search: search,
|
||||
|
@ -116,7 +115,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
|||
agentVersions: agentVersions,
|
||||
edgeCheckInPassedSeconds: edgeCheckInPassedSeconds,
|
||||
edgeStackId: portainer.EdgeStackID(edgeStackId),
|
||||
edgeStackStatus: EdgeStackStatusFilter(edgeStackStatus),
|
||||
edgeStackStatus: edgeStackStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@ -213,30 +212,21 @@ func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.End
|
|||
return filteredEndpoints, totalAvailableEndpoints, nil
|
||||
}
|
||||
|
||||
func endpointStatusInStackMatchesFilter(stackStatus map[portainer.EndpointID]portainer.EdgeStackStatus, envId portainer.EndpointID, statusFilter EdgeStackStatusFilter) bool {
|
||||
status, ok := stackStatus[envId]
|
||||
func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID]portainer.EdgeStackStatus, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
|
||||
status, ok := edgeStackStatus[envId]
|
||||
|
||||
// consider that if the env has no status in the stack it is in Pending state
|
||||
// workaround because Stack.Status[EnvId].Details.Pending is never set to True in the codebase
|
||||
if !ok && statusFilter == statusFilterPending {
|
||||
if !ok && statusFilter == portainer.EdgeStackStatusPending {
|
||||
return true
|
||||
}
|
||||
|
||||
valueMap := map[EdgeStackStatusFilter]bool{
|
||||
statusFilterPending: status.Details.Pending,
|
||||
statusFilterOk: status.Details.Ok,
|
||||
statusFilterError: status.Details.Error,
|
||||
statusFilterAcknowledged: status.Details.Acknowledged,
|
||||
statusFilterRemove: status.Details.Remove,
|
||||
statusFilterRemoteUpdateSuccess: status.Details.RemoteUpdateSuccess,
|
||||
statusFilterImagesPulled: status.Details.ImagesPulled,
|
||||
}
|
||||
|
||||
currentStatus, ok := valueMap[statusFilter]
|
||||
return ok && currentStatus
|
||||
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
|
||||
return s.Type == statusFilter
|
||||
})
|
||||
}
|
||||
|
||||
func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId portainer.EdgeStackID, statusFilter EdgeStackStatusFilter, datastore dataservices.DataStore) ([]portainer.Endpoint, error) {
|
||||
func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId portainer.EdgeStackID, statusFilter *portainer.EdgeStackStatusType, datastore dataservices.DataStore) ([]portainer.Endpoint, error) {
|
||||
stack, err := datastore.EdgeStack().EdgeStack(edgeStackId)
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database")
|
||||
|
@ -258,10 +248,10 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
|
|||
envIds = append(envIds, edgeGroup.Endpoints...)
|
||||
}
|
||||
|
||||
if statusFilter != "" {
|
||||
if statusFilter != nil {
|
||||
n := 0
|
||||
for _, envId := range envIds {
|
||||
if endpointStatusInStackMatchesFilter(stack.Status, envId, statusFilter) {
|
||||
if endpointStatusInStackMatchesFilter(stack.Status, envId, *statusFilter) {
|
||||
envIds[n] = envId
|
||||
n++
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package endpoints
|
||||
|
||||
func ptr[T any](i T) *T { return &i }
|
||||
|
||||
func BoolAddr(b bool) *bool {
|
||||
boolVar := b
|
||||
return &boolVar
|
||||
return ptr(b)
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ func (service *Service) BuildEdgeStack(
|
|||
DeploymentType: deploymentType,
|
||||
CreationDate: time.Now().Unix(),
|
||||
EdgeGroups: edgeGroups,
|
||||
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
||||
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus, 0),
|
||||
Version: 1,
|
||||
UseManifestNamespaces: useManifestNamespaces,
|
||||
}, nil
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
package edgestacks
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// NewStatus returns a new status object for an Edge stack
|
||||
func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, relatedEnvironmentIDs []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeStackStatus {
|
||||
status := map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
||||
|
||||
for _, environmentID := range relatedEnvironmentIDs {
|
||||
|
||||
newEnvStatus := portainer.EdgeStackStatus{
|
||||
Status: []portainer.EdgeStackDeploymentStatus{},
|
||||
EndpointID: portainer.EndpointID(environmentID),
|
||||
}
|
||||
|
||||
oldEnvStatus, ok := oldStatus[environmentID]
|
||||
if ok {
|
||||
newEnvStatus.DeploymentInfo = oldEnvStatus.DeploymentInfo
|
||||
}
|
||||
|
||||
status[environmentID] = newEnvStatus
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
|
@ -2,14 +2,33 @@ package slices
|
|||
|
||||
// Contains is a generic function that returns true if the element is contained within the slice
|
||||
func Contains[T comparable](elems []T, v T) bool {
|
||||
return ContainsFunc(elems, func(s T) bool {
|
||||
return s == v
|
||||
})
|
||||
}
|
||||
|
||||
// Contains is a generic function that returns true if the element is contained within the slice
|
||||
func ContainsFunc[T any](elems []T, f func(T) bool) bool {
|
||||
for _, s := range elems {
|
||||
if v == s {
|
||||
if f(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Find[T any](elems []T, f func(T) bool) (T, bool) {
|
||||
for _, s := range elems {
|
||||
if f(s) {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
|
||||
// return default value
|
||||
var result T
|
||||
return result, false
|
||||
}
|
||||
|
||||
// IndexFunc returns the first index i satisfying f(s[i]),
|
||||
// or -1 if none do.
|
||||
func IndexFunc[E any](s []E, f func(E) bool) int {
|
||||
|
|
|
@ -310,15 +310,16 @@ type (
|
|||
//EdgeStack represents an edge stack
|
||||
EdgeStack struct {
|
||||
// EdgeStack Identifier
|
||||
ID EdgeStackID `json:"Id" example:"1"`
|
||||
Name string `json:"Name"`
|
||||
Status map[EndpointID]EdgeStackStatus `json:"Status"`
|
||||
CreationDate int64 `json:"CreationDate"`
|
||||
EdgeGroups []EdgeGroupID `json:"EdgeGroups"`
|
||||
ProjectPath string `json:"ProjectPath"`
|
||||
EntryPoint string `json:"EntryPoint"`
|
||||
Version int `json:"Version"`
|
||||
NumDeployments int `json:"NumDeployments"`
|
||||
ID EdgeStackID `json:"Id" example:"1"`
|
||||
Name string `json:"Name"`
|
||||
Status map[EndpointID]EdgeStackStatus `json:"Status"`
|
||||
// StatusArray map[EndpointID][]EdgeStackStatus `json:"StatusArray"`
|
||||
CreationDate int64 `json:"CreationDate"`
|
||||
EdgeGroups []EdgeGroupID `json:"EdgeGroups"`
|
||||
ProjectPath string `json:"ProjectPath"`
|
||||
EntryPoint string `json:"EntryPoint"`
|
||||
Version int `json:"Version"`
|
||||
NumDeployments int `json:"NumDeployments"`
|
||||
ManifestPath string
|
||||
DeploymentType EdgeStackDeploymentType
|
||||
// Uses the manifest's namespaces instead of the default one
|
||||
|
@ -345,16 +346,26 @@ type (
|
|||
|
||||
//EdgeStackStatus represents an edge stack status
|
||||
EdgeStackStatus struct {
|
||||
Details EdgeStackStatusDetails `json:"Details"`
|
||||
Error string `json:"Error"`
|
||||
EndpointID EndpointID `json:"EndpointID"`
|
||||
Status []EdgeStackDeploymentStatus
|
||||
EndpointID EndpointID
|
||||
// EE only feature
|
||||
DeploymentInfo StackDeploymentInfo `json:"DeploymentInfo"`
|
||||
DeploymentInfo StackDeploymentInfo
|
||||
|
||||
// Deprecated
|
||||
Details EdgeStackStatusDetails
|
||||
// Deprecated
|
||||
Error string
|
||||
// Deprecated
|
||||
Type EdgeStackStatusType `json:"Type"`
|
||||
}
|
||||
|
||||
// EdgeStackDeploymentStatus represents an edge stack deployment status
|
||||
EdgeStackDeploymentStatus struct {
|
||||
Time int64
|
||||
Type EdgeStackStatusType
|
||||
Error string
|
||||
}
|
||||
|
||||
//EdgeStackStatusType represents an edge stack status type
|
||||
EdgeStackStatusType int
|
||||
|
||||
|
@ -1647,18 +1658,24 @@ const (
|
|||
const (
|
||||
// EdgeStackStatusPending represents a pending edge stack
|
||||
EdgeStackStatusPending EdgeStackStatusType = iota
|
||||
//EdgeStackStatusOk represents a successfully deployed edge stack
|
||||
EdgeStackStatusOk
|
||||
//EdgeStackStatusError represents an edge environment(endpoint) which failed to deploy its edge stack
|
||||
//EdgeStackStatusDeploymentReceived represents an edge environment which received the edge stack deployment
|
||||
EdgeStackStatusDeploymentReceived
|
||||
//EdgeStackStatusError represents an edge environment which failed to deploy its edge stack
|
||||
EdgeStackStatusError
|
||||
//EdgeStackStatusAcknowledged represents an acknowledged edge stack
|
||||
EdgeStackStatusAcknowledged
|
||||
//EdgeStackStatusRemove represents a removed edge stack (status isn't persisted)
|
||||
EdgeStackStatusRemove
|
||||
//EdgeStackStatusRemoved represents a removed edge stack
|
||||
EdgeStackStatusRemoved
|
||||
// StatusRemoteUpdateSuccess represents a successfully updated edge stack
|
||||
EdgeStackStatusRemoteUpdateSuccess
|
||||
// EdgeStackStatusImagesPulled represents a successfully images-pulling
|
||||
EdgeStackStatusImagesPulled
|
||||
// EdgeStackStatusRunning represents a running Edge stack
|
||||
EdgeStackStatusRunning
|
||||
// EdgeStackStatusDeploying represents an Edge stack which is being deployed
|
||||
EdgeStackStatusDeploying
|
||||
// EdgeStackStatusRemoving represents an Edge stack which is being removed
|
||||
EdgeStackStatusRemoving
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" width="14" height="14" rx="7" fill="#039855"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4729 4.31086L6.29623 8.34169L5.1879 7.15753C4.98373 6.96503 4.6629 6.95336 4.42957 7.11669C4.20207 7.28586 4.1379 7.58336 4.2779 7.82253L5.5904 9.95753C5.71873 10.1559 5.9404 10.2784 6.19123 10.2784C6.4304 10.2784 6.6579 10.1559 6.78623 9.95753C6.99623 9.68336 11.0037 4.90586 11.0037 4.90586C11.5287 4.36919 10.8929 3.89669 10.4729 4.30503V4.31086Z" fill="#0BA5EC"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1403 4.30503C11.0118 4.12405 10.7084 4.07605 10.4729 4.30503V4.31086L10.3899 4.39096L6.29623 8.34169L5.1879 7.15753C4.98373 6.96503 4.6629 6.95336 4.42957 7.11669C4.20207 7.28586 4.1379 7.58336 4.2779 7.82253L5.5904 9.95753C5.69222 10.1149 5.85279 10.2245 6.04006 10.2631C6.08883 10.2731 6.13941 10.2784 6.19123 10.2784C6.31863 10.2784 6.44272 10.2436 6.55028 10.1811C6.64463 10.1263 6.72626 10.0502 6.78623 9.95753C6.78962 9.9531 6.794 9.94751 6.79933 9.94078C6.85798 9.86677 7.03221 9.65571 7.27843 9.35943C8.07017 8.40673 9.60624 6.57293 10.4372 5.5816C10.7807 5.17172 11.0037 4.90586 11.0037 4.90586C11.0111 4.89829 11.0183 4.89074 11.0253 4.8832C11.0268 4.88147 11.0284 4.87975 11.03 4.87803C11.2318 4.65583 11.2369 4.44719 11.1444 4.31086C11.143 4.3089 11.1417 4.30696 11.1403 4.30503Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
|
@ -0,0 +1,11 @@
|
|||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_0_118)">
|
||||
<rect x="0.5" width="14" height="14" rx="7" fill="#F79009"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.81723 4.55883C5.11013 4.26593 5.585 4.26593 5.87789 4.55883L7.55801 6.23895L9.23813 4.55883C9.53103 4.26593 10.0059 4.26593 10.2988 4.55883C10.5917 4.85172 10.5917 5.32659 10.2988 5.61949L8.61867 7.29961L10.2988 8.97973C10.5917 9.27262 10.5917 9.74749 10.2988 10.0404C10.0059 10.3333 9.53103 10.3333 9.23813 10.0404L7.55801 8.36027L5.87789 10.0404C5.585 10.3333 5.11013 10.3333 4.81723 10.0404C4.52434 9.74749 4.52434 9.27262 4.81723 8.97973L6.49735 7.29961L4.81723 5.61949C4.52434 5.32659 4.52434 4.85172 4.81723 4.55883Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_118">
|
||||
<rect x="0.5" width="14" height="14" rx="7" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 908 B |
|
@ -13,9 +13,11 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
|
|||
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
||||
import { EdgeStackStatus } from '@/react/edge/edge-stacks/ListView/EdgeStackStatus';
|
||||
|
||||
export const componentsModule = angular
|
||||
.module('portainer.edge.react.components', [])
|
||||
.component('edgeStacksDatatableStatus', r2a(EdgeStackStatus, ['edgeStack']))
|
||||
.component(
|
||||
'edgeStackEnvironmentsDatatable',
|
||||
r2a(withUIRouter(withReactQuery(EnvironmentsDatatable)), [])
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import _ from 'lodash-es';
|
||||
import './datatable.css';
|
||||
import { ResourceControlOwnership as RCO } from '@/react/portainer/access-control/types';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
function isBetween(value, a, b) {
|
||||
return (value >= a && value <= b) || (value >= b && value <= a);
|
||||
|
@ -14,6 +15,7 @@ angular.module('portainer.app').controller('GenericDatatableController', [
|
|||
'PAGINATION_MAX_ITEMS',
|
||||
function ($interval, PaginationService, DatatableService, PAGINATION_MAX_ITEMS) {
|
||||
this.RCO = RCO;
|
||||
this.isBE = isBE;
|
||||
|
||||
this.state = {
|
||||
selectAll: false,
|
||||
|
|
|
@ -120,7 +120,7 @@ export const ngModule = angular
|
|||
'fallbackImage',
|
||||
r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'size', 'className'])
|
||||
)
|
||||
.component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size']))
|
||||
.component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin']))
|
||||
.component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, []))
|
||||
.component(
|
||||
'dashboardItem',
|
||||
|
|
|
@ -29,15 +29,15 @@ interface Props {
|
|||
className?: string;
|
||||
size?: IconSize;
|
||||
mode?: IconMode;
|
||||
spin?: boolean;
|
||||
}
|
||||
|
||||
export function Icon({ icon, className, mode, size }: Props) {
|
||||
const classes = clsx(
|
||||
className,
|
||||
'icon inline-flex',
|
||||
{ [`icon-${mode}`]: mode },
|
||||
{ [`icon-${size}`]: size }
|
||||
);
|
||||
export function Icon({ icon, className, mode, size, spin }: Props) {
|
||||
const classes = clsx(className, 'icon inline-flex', {
|
||||
[`icon-${mode}`]: mode,
|
||||
[`icon-${size}`]: size,
|
||||
'animate-spin-slow': spin,
|
||||
});
|
||||
|
||||
if (typeof icon !== 'string') {
|
||||
const Icon = isValidElementType(icon) ? icon : null;
|
||||
|
|
|
@ -6,6 +6,7 @@ import { EdgeStackStatus, StatusType } from '@/react/edge/edge-stacks/types';
|
|||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||
|
@ -20,17 +21,29 @@ export function EnvironmentsDatatable() {
|
|||
const {
|
||||
params: { stackId },
|
||||
} = useCurrentStateAndParams();
|
||||
const edgeStackQuery = useEdgeStack(stackId);
|
||||
const edgeStackQuery = useEdgeStack(stackId, {
|
||||
refetchInterval(data) {
|
||||
if (!data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Object.values(data.Status).some((status) =>
|
||||
status.Status.every((s) => s.Type === StatusType.Running)
|
||||
)
|
||||
? 0
|
||||
: 10000;
|
||||
},
|
||||
});
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useParamState<StatusType>(
|
||||
'status',
|
||||
parseStatusFilter
|
||||
(value) => (value ? parseInt(value, 10) : undefined)
|
||||
);
|
||||
const tableState = useTableStateWithoutStorage('name');
|
||||
const endpointsQuery = useEnvironmentList({
|
||||
pageLimit: tableState.pageSize,
|
||||
page,
|
||||
page: page + 1,
|
||||
search: tableState.search,
|
||||
sort: tableState.sortBy.id as 'Group' | 'Name',
|
||||
order: tableState.sortBy.desc ? 'desc' : 'asc',
|
||||
|
@ -38,27 +51,32 @@ export function EnvironmentsDatatable() {
|
|||
edgeStackStatus: statusFilter,
|
||||
});
|
||||
|
||||
const currentFileVersion =
|
||||
edgeStackQuery.data?.StackFileVersion?.toString() || '';
|
||||
const gitConfigURL = edgeStackQuery.data?.GitConfig?.URL || '';
|
||||
const gitConfigCommitHash = edgeStackQuery.data?.GitConfig?.ConfigHash || '';
|
||||
const environments: Array<EdgeStackEnvironment> = useMemo(
|
||||
() =>
|
||||
endpointsQuery.environments.map((env) => ({
|
||||
...env,
|
||||
StackStatus:
|
||||
edgeStackQuery.data?.Status[env.Id] ||
|
||||
endpointsQuery.environments.map(
|
||||
(env) =>
|
||||
({
|
||||
Details: {
|
||||
Pending: true,
|
||||
Acknowledged: false,
|
||||
ImagesPulled: false,
|
||||
Error: false,
|
||||
Ok: false,
|
||||
RemoteUpdateSuccess: false,
|
||||
Remove: false,
|
||||
},
|
||||
EndpointID: env.Id,
|
||||
Error: '',
|
||||
} satisfies EdgeStackStatus),
|
||||
})),
|
||||
[edgeStackQuery.data?.Status, endpointsQuery.environments]
|
||||
...env,
|
||||
TargetFileVersion: currentFileVersion,
|
||||
GitConfigURL: gitConfigURL,
|
||||
TargetCommitHash: gitConfigCommitHash,
|
||||
StackStatus: getEnvStackStatus(
|
||||
env.Id,
|
||||
edgeStackQuery.data?.Status[env.Id]
|
||||
),
|
||||
} satisfies EdgeStackEnvironment)
|
||||
),
|
||||
[
|
||||
currentFileVersion,
|
||||
edgeStackQuery.data?.Status,
|
||||
endpointsQuery.environments,
|
||||
gitConfigCommitHash,
|
||||
gitConfigURL,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -81,11 +99,11 @@ export function EnvironmentsDatatable() {
|
|||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e || undefined)}
|
||||
options={[
|
||||
{ value: 'Pending', label: 'Pending' },
|
||||
{ value: 'Acknowledged', label: 'Acknowledged' },
|
||||
{ value: 'ImagesPulled', label: 'Images pre-pulled' },
|
||||
{ value: 'Ok', label: 'Deployed' },
|
||||
{ value: 'Error', label: 'Failed' },
|
||||
{ value: StatusType.Pending, label: 'Pending' },
|
||||
{ value: StatusType.Acknowledged, label: 'Acknowledged' },
|
||||
{ value: StatusType.ImagesPulled, label: 'Images pre-pulled' },
|
||||
{ value: StatusType.Running, label: 'Deployed' },
|
||||
{ value: StatusType.Error, label: 'Failed' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
@ -95,19 +113,31 @@ export function EnvironmentsDatatable() {
|
|||
);
|
||||
}
|
||||
|
||||
function parseStatusFilter(status: string | undefined): StatusType | undefined {
|
||||
switch (status) {
|
||||
case 'Pending':
|
||||
return 'Pending';
|
||||
case 'Acknowledged':
|
||||
return 'Acknowledged';
|
||||
case 'ImagesPulled':
|
||||
return 'ImagesPulled';
|
||||
case 'Ok':
|
||||
return 'Ok';
|
||||
case 'Error':
|
||||
return 'Error';
|
||||
default:
|
||||
return undefined;
|
||||
function getEnvStackStatus(
|
||||
envId: EnvironmentId,
|
||||
envStatus: EdgeStackStatus | undefined
|
||||
) {
|
||||
const pendingStatus = {
|
||||
Type: StatusType.Pending,
|
||||
Error: '',
|
||||
Time: new Date().valueOf() / 1000,
|
||||
};
|
||||
|
||||
let status = envStatus;
|
||||
if (!status) {
|
||||
status = {
|
||||
EndpointID: envId,
|
||||
DeploymentInfo: {
|
||||
ConfigHash: '',
|
||||
FileVersion: 0,
|
||||
},
|
||||
Status: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (status.Status.length === 0) {
|
||||
status.Status.push(pendingStatus);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
|
|
@ -2,13 +2,19 @@ import { CellContext, createColumnHelper } from '@tanstack/react-table';
|
|||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import UpdatesAvailable from '@/assets/ico/icon_updates-available.svg?c';
|
||||
import UpToDate from '@/assets/ico/icon_up-to-date.svg?c';
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { getDashboardRoute } from '@/react/portainer/environments/utils';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { EdgeStackStatus } from '../../types';
|
||||
import { DeploymentStatus, EdgeStackStatus, StatusType } from '../../types';
|
||||
|
||||
import { EnvironmentActions } from './EnvironmentActions';
|
||||
import { ActionStatus } from './ActionStatus';
|
||||
|
@ -16,20 +22,75 @@ import { EdgeStackEnvironment } from './types';
|
|||
|
||||
const columnHelper = createColumnHelper<EdgeStackEnvironment>();
|
||||
|
||||
export const columns = [
|
||||
export const columns = _.compact([
|
||||
columnHelper.accessor('Name', {
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
cell({ row: { original: env } }) {
|
||||
const { to, params } = getDashboardRoute(env);
|
||||
return (
|
||||
<Link to={to} params={params}>
|
||||
{env.Name}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((env) => endpointStatusLabel(env.StackStatus), {
|
||||
columnHelper.accessor((env) => endpointStatusLabel(env.StackStatus.Status), {
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell({ row: { original: env } }) {
|
||||
return (
|
||||
<ul className="list-none space-y-2">
|
||||
{env.StackStatus.Status.map((s) => (
|
||||
<li key={`status-${s.Type}-${s.Time}`}>
|
||||
<Status value={s.Type} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((env) => env.StackStatus.Error, {
|
||||
id: 'error',
|
||||
header: 'Error',
|
||||
cell: ErrorCell,
|
||||
columnHelper.accessor((env) => _.last(env.StackStatus.Status)?.Time, {
|
||||
id: 'statusDate',
|
||||
header: 'Time',
|
||||
cell({ row: { original: env } }) {
|
||||
return (
|
||||
<ul className="list-none space-y-2">
|
||||
{env.StackStatus.Status.map((s) => (
|
||||
<li key={`time-${s.Type}-${s.Time}`}>
|
||||
{isoDateFromTimestamp(s.Time)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
}),
|
||||
...(isBE
|
||||
? [
|
||||
columnHelper.accessor((env) => endpointTargetVersionLabel(env), {
|
||||
id: 'targetVersion',
|
||||
header: 'Target version',
|
||||
cell: TargetVersionCell,
|
||||
}),
|
||||
columnHelper.accessor(
|
||||
(env) => endpointDeployedVersionLabel(env.StackStatus),
|
||||
{
|
||||
id: 'deployedVersion',
|
||||
header: 'Deployed version',
|
||||
cell: DeployedVersionCell,
|
||||
}
|
||||
),
|
||||
]
|
||||
: []),
|
||||
columnHelper.accessor(
|
||||
(env) =>
|
||||
env.StackStatus.Status.find((s) => s.Type === StatusType.Error)?.Error,
|
||||
{
|
||||
id: 'error',
|
||||
header: 'Error',
|
||||
cell: ErrorCell,
|
||||
}
|
||||
),
|
||||
...(isBE
|
||||
? [
|
||||
columnHelper.display({
|
||||
|
@ -48,7 +109,7 @@ export const columns = [
|
|||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
]);
|
||||
|
||||
function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
@ -77,30 +138,151 @@ function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
|
|||
);
|
||||
}
|
||||
|
||||
function endpointStatusLabel(status: EdgeStackStatus) {
|
||||
const details = (status && status.Details) || {};
|
||||
|
||||
function endpointStatusLabel(statusArray: Array<DeploymentStatus>) {
|
||||
const labels = [];
|
||||
|
||||
if (details.Acknowledged) {
|
||||
labels.push('Acknowledged');
|
||||
}
|
||||
|
||||
if (details.ImagesPulled) {
|
||||
labels.push('Images pre-pulled');
|
||||
}
|
||||
|
||||
if (details.Ok) {
|
||||
labels.push('Deployed');
|
||||
}
|
||||
|
||||
if (details.Error) {
|
||||
labels.push('Failed');
|
||||
}
|
||||
statusArray.forEach((status) => {
|
||||
if (status.Type === StatusType.Acknowledged) {
|
||||
labels.push('Acknowledged');
|
||||
}
|
||||
if (status.Type === StatusType.ImagesPulled) {
|
||||
labels.push('Images pre-pulled');
|
||||
}
|
||||
if (status.Type === StatusType.Running) {
|
||||
labels.push('Deployed');
|
||||
}
|
||||
if (status.Type === StatusType.Error) {
|
||||
labels.push('Failed');
|
||||
}
|
||||
});
|
||||
|
||||
if (!labels.length) {
|
||||
labels.push('Pending');
|
||||
}
|
||||
|
||||
return labels.join(', ');
|
||||
return _.uniq(labels).join(', ');
|
||||
}
|
||||
|
||||
function TargetVersionCell({
|
||||
row,
|
||||
getValue,
|
||||
}: CellContext<EdgeStackEnvironment, string>) {
|
||||
const value = getValue();
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{row.original.TargetCommitHash ? (
|
||||
<div>
|
||||
<a
|
||||
href={`${row.original.GitConfigURL}/commit/${row.original.TargetCommitHash}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div>{value}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function endpointTargetVersionLabel(env: EdgeStackEnvironment) {
|
||||
if (env.TargetCommitHash) {
|
||||
return env.TargetCommitHash.slice(0, 7).toString();
|
||||
}
|
||||
return env.TargetFileVersion.toString() || '';
|
||||
}
|
||||
|
||||
function DeployedVersionCell({
|
||||
row,
|
||||
getValue,
|
||||
}: CellContext<EdgeStackEnvironment, string>) {
|
||||
const value = getValue();
|
||||
if (!value || value === '0') {
|
||||
return (
|
||||
<div>
|
||||
<Icon icon={UpdatesAvailable} className="!mr-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let statusIcon = <Icon icon={UpToDate} className="!mr-2" />;
|
||||
if (
|
||||
(row.original.TargetCommitHash &&
|
||||
row.original.TargetCommitHash.slice(0, 7) !== value) ||
|
||||
(!row.original.TargetCommitHash && row.original.TargetFileVersion !== value)
|
||||
) {
|
||||
statusIcon = <Icon icon={UpdatesAvailable} className="!mr-2" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{row.original.TargetCommitHash ? (
|
||||
<div>
|
||||
{statusIcon}
|
||||
<a
|
||||
href={`${row.original.GitConfigURL}/commit/${row.original.TargetCommitHash}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{statusIcon}
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function endpointDeployedVersionLabel(status: EdgeStackStatus) {
|
||||
if (status.DeploymentInfo?.ConfigHash) {
|
||||
return status.DeploymentInfo?.ConfigHash.slice(0, 7).toString();
|
||||
}
|
||||
return status.DeploymentInfo?.FileVersion.toString() || '';
|
||||
}
|
||||
|
||||
function Status({ value }: { value: StatusType }) {
|
||||
const color = getStateColor(value);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={clsx('h-2 w-2 rounded-full', {
|
||||
'bg-orange-5': color === 'orange',
|
||||
'bg-green-5': color === 'green',
|
||||
'bg-error-5': color === 'red',
|
||||
})}
|
||||
/>
|
||||
|
||||
<span>{_.startCase(StatusType[value])}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStateColor(type: StatusType): 'orange' | 'green' | 'red' {
|
||||
switch (type) {
|
||||
case StatusType.Acknowledged:
|
||||
case StatusType.ImagesPulled:
|
||||
case StatusType.DeploymentReceived:
|
||||
case StatusType.Running:
|
||||
case StatusType.RemoteUpdateSuccess:
|
||||
case StatusType.Removed:
|
||||
return 'green';
|
||||
case StatusType.Error:
|
||||
return 'red';
|
||||
case StatusType.Pending:
|
||||
case StatusType.Deploying:
|
||||
case StatusType.Removing:
|
||||
default:
|
||||
return 'orange';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,4 +4,7 @@ import { EdgeStackStatus } from '../../types';
|
|||
|
||||
export type EdgeStackEnvironment = Environment & {
|
||||
StackStatus: EdgeStackStatus;
|
||||
TargetFileVersion: string;
|
||||
GitConfigURL: string;
|
||||
TargetCommitHash: string;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
import _ from 'lodash';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
type Icon as IconType,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Icon, IconMode } from '@@/Icon';
|
||||
|
||||
import { DeploymentStatus, EdgeStack, StatusType } from '../types';
|
||||
|
||||
export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
|
||||
const status = Object.values(edgeStack.Status);
|
||||
const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
|
||||
|
||||
const { icon, label, mode, spin } = getStatus(
|
||||
edgeStack.NumDeployments,
|
||||
lastStatus
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto inline-flex items-center gap-2">
|
||||
{icon && <Icon icon={icon} spin={spin} mode={mode} />}
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatus(
|
||||
numDeployments: number,
|
||||
envStatus: Array<DeploymentStatus>
|
||||
): {
|
||||
label: string;
|
||||
icon?: IconType;
|
||||
spin?: boolean;
|
||||
mode?: IconMode;
|
||||
} {
|
||||
if (envStatus.length < numDeployments) {
|
||||
return {
|
||||
label: 'Deploying',
|
||||
icon: Loader2,
|
||||
spin: true,
|
||||
mode: 'primary',
|
||||
};
|
||||
}
|
||||
|
||||
const allFailed = envStatus.every((s) => s.Type === StatusType.Error);
|
||||
|
||||
if (allFailed) {
|
||||
return {
|
||||
label: 'Failed',
|
||||
icon: XCircle,
|
||||
mode: 'danger',
|
||||
};
|
||||
}
|
||||
|
||||
const allRunning = envStatus.every((s) => s.Type === StatusType.Running);
|
||||
|
||||
if (allRunning) {
|
||||
return {
|
||||
label: 'Running',
|
||||
icon: CheckCircle,
|
||||
mode: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
const hasDeploying = envStatus.some((s) => s.Type === StatusType.Deploying);
|
||||
const hasRunning = envStatus.some((s) => s.Type === StatusType.Running);
|
||||
const hasFailed = envStatus.some((s) => s.Type === StatusType.Error);
|
||||
|
||||
if (hasRunning && hasFailed && !hasDeploying) {
|
||||
return {
|
||||
label: 'Partially Running',
|
||||
icon: AlertTriangle,
|
||||
mode: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Deploying',
|
||||
icon: Loader2,
|
||||
spin: true,
|
||||
mode: 'primary',
|
||||
};
|
||||
}
|
|
@ -38,10 +38,10 @@ export function DeploymentCounter({
|
|||
return (
|
||||
<span
|
||||
className={clsx(styles.root, {
|
||||
[styles.statusOk]: type === 'Ok',
|
||||
[styles.statusError]: type === 'Error',
|
||||
[styles.statusAcknowledged]: type === 'Acknowledged',
|
||||
[styles.statusImagesPulled]: type === 'ImagesPulled',
|
||||
[styles.statusOk]: type === StatusType.Running,
|
||||
[styles.statusError]: type === StatusType.Error,
|
||||
[styles.statusAcknowledged]: type === StatusType.Acknowledged,
|
||||
[styles.statusImagesPulled]: type === StatusType.ImagesPulled,
|
||||
[styles.statusTotal]: type === undefined,
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Datatable } from '@@/datatables';
|
|||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useEdgeStacks } from '../../queries/useEdgeStacks';
|
||||
import { EdgeStack } from '../../types';
|
||||
import { EdgeStack, StatusType } from '../../types';
|
||||
|
||||
import { createStore } from './store';
|
||||
import { columns } from './columns';
|
||||
|
@ -51,11 +51,16 @@ export function EdgeStacksDatatable() {
|
|||
|
||||
function aggregateStackStatus(stackStatus: EdgeStack['Status']) {
|
||||
const aggregateStatus = { ok: 0, error: 0, acknowledged: 0, imagesPulled: 0 };
|
||||
return Object.values(stackStatus).reduce((acc, envStatus) => {
|
||||
acc.ok += Number(envStatus.Details.Ok);
|
||||
acc.error += Number(envStatus.Details.Error);
|
||||
acc.acknowledged += Number(envStatus.Details.Acknowledged);
|
||||
acc.imagesPulled += Number(envStatus.Details.ImagesPulled);
|
||||
return acc;
|
||||
}, aggregateStatus);
|
||||
return Object.values(stackStatus).reduce(
|
||||
(acc, envStatus) =>
|
||||
envStatus.Status.reduce((acc, status) => {
|
||||
const { Type } = status;
|
||||
acc.ok += Number(Type === StatusType.Running);
|
||||
acc.error += Number(Type === StatusType.Error);
|
||||
acc.acknowledged += Number(Type === StatusType.Acknowledged);
|
||||
acc.imagesPulled += Number(Type === StatusType.ImagesPulled);
|
||||
return acc;
|
||||
}, acc),
|
||||
aggregateStatus
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
import _ from 'lodash';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
type Icon as IconType,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Icon, IconMode } from '@@/Icon';
|
||||
|
||||
import { DeploymentStatus, EdgeStack, StatusType } from '../../types';
|
||||
|
||||
export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
|
||||
const status = Object.values(edgeStack.Status);
|
||||
const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
|
||||
|
||||
const { icon, label, mode, spin } = getStatus(
|
||||
edgeStack.NumDeployments,
|
||||
lastStatus
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto inline-flex items-center gap-2">
|
||||
{icon && <Icon icon={icon} spin={spin} mode={mode} />}
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatus(
|
||||
numDeployments: number,
|
||||
envStatus: Array<DeploymentStatus>
|
||||
): {
|
||||
label: string;
|
||||
icon?: IconType;
|
||||
spin?: boolean;
|
||||
mode?: IconMode;
|
||||
} {
|
||||
if (envStatus.length < numDeployments) {
|
||||
return {
|
||||
label: 'Deploying',
|
||||
icon: Loader2,
|
||||
spin: true,
|
||||
mode: 'primary',
|
||||
};
|
||||
}
|
||||
|
||||
const allFailed = envStatus.every((s) => s.Type === StatusType.Error);
|
||||
|
||||
if (allFailed) {
|
||||
return {
|
||||
label: 'Failed',
|
||||
icon: XCircle,
|
||||
mode: 'danger',
|
||||
};
|
||||
}
|
||||
|
||||
const allRunning = envStatus.every((s) => s.Type === StatusType.Running);
|
||||
|
||||
if (allRunning) {
|
||||
return {
|
||||
label: 'Running',
|
||||
icon: CheckCircle,
|
||||
mode: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
const hasDeploying = envStatus.some((s) => s.Type === StatusType.Deploying);
|
||||
const hasRunning = envStatus.some((s) => s.Type === StatusType.Running);
|
||||
const hasFailed = envStatus.some((s) => s.Type === StatusType.Error);
|
||||
|
||||
if (hasRunning && hasFailed && !hasDeploying) {
|
||||
return {
|
||||
label: 'Partially Running',
|
||||
icon: AlertTriangle,
|
||||
mode: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Deploying',
|
||||
icon: Loader2,
|
||||
spin: true,
|
||||
mode: 'primary',
|
||||
};
|
||||
}
|
|
@ -6,6 +6,9 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
|||
|
||||
import { buildNameColumn } from '@@/datatables/NameCell';
|
||||
|
||||
import { StatusType } from '../../types';
|
||||
import { EdgeStackStatus } from '../EdgeStackStatus';
|
||||
|
||||
import { DecoratedEdgeStack } from './types';
|
||||
import { DeploymentCounter, DeploymentCounterLink } from './DeploymentCounter';
|
||||
|
||||
|
@ -25,7 +28,7 @@ export const columns = _.compact([
|
|||
cell: ({ getValue, row }) => (
|
||||
<DeploymentCounterLink
|
||||
count={getValue()}
|
||||
type="Acknowledged"
|
||||
type={StatusType.Acknowledged}
|
||||
stackId={row.original.Id}
|
||||
/>
|
||||
),
|
||||
|
@ -39,7 +42,7 @@ export const columns = _.compact([
|
|||
cell: ({ getValue, row }) => (
|
||||
<DeploymentCounterLink
|
||||
count={getValue()}
|
||||
type="ImagesPulled"
|
||||
type={StatusType.ImagesPulled}
|
||||
stackId={row.original.Id}
|
||||
/>
|
||||
),
|
||||
|
@ -54,7 +57,7 @@ export const columns = _.compact([
|
|||
cell: ({ getValue, row }) => (
|
||||
<DeploymentCounterLink
|
||||
count={getValue()}
|
||||
type="Ok"
|
||||
type={StatusType.Running}
|
||||
stackId={row.original.Id}
|
||||
/>
|
||||
),
|
||||
|
@ -69,7 +72,7 @@ export const columns = _.compact([
|
|||
cell: ({ getValue, row }) => (
|
||||
<DeploymentCounterLink
|
||||
count={getValue()}
|
||||
type="Error"
|
||||
type={StatusType.Error}
|
||||
stackId={row.original.Id}
|
||||
/>
|
||||
),
|
||||
|
@ -79,6 +82,19 @@ export const columns = _.compact([
|
|||
className: '[&>*]:justify-center',
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('Status', {
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<div className="w-full text-center">
|
||||
<EdgeStackStatus edgeStack={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
className: '[&>*]:justify-center',
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('NumDeployments', {
|
||||
header: 'Deployments',
|
||||
cell: ({ getValue }) => (
|
||||
|
|
|
@ -8,10 +8,24 @@ import { EdgeStack } from '../types';
|
|||
import { buildUrl } from './buildUrl';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useEdgeStack(id?: EdgeStack['Id']) {
|
||||
export function useEdgeStack(
|
||||
id?: EdgeStack['Id'],
|
||||
{
|
||||
refetchInterval,
|
||||
}: {
|
||||
/**
|
||||
* If set to a number, the query will continuously refetch at this frequency in milliseconds. If set to a function, the function will be executed with the latest data and query to compute a frequency Defaults to false.
|
||||
*/
|
||||
refetchInterval?:
|
||||
| number
|
||||
| false
|
||||
| ((data?: Awaited<ReturnType<typeof getEdgeStack>>) => false | number);
|
||||
} = {}
|
||||
) {
|
||||
return useQuery(id ? queryKeys.item(id) : [], () => getEdgeStack(id), {
|
||||
...withError('Failed loading Edge stack'),
|
||||
enabled: !!id,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|||
import { EdgeStack } from '../types';
|
||||
|
||||
import { buildUrl } from './buildUrl';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useEdgeStacks<T = Array<EdgeStack>>({
|
||||
select,
|
||||
|
@ -19,7 +20,7 @@ export function useEdgeStacks<T = Array<EdgeStack>>({
|
|||
select?: (stacks: EdgeStack[]) => T;
|
||||
refetchInterval?: number | false | ((data?: T) => false | number);
|
||||
} = {}) {
|
||||
return useQuery(['edge_stacks'], () => getEdgeStacks(), {
|
||||
return useQuery(queryKeys.base(), () => getEdgeStacks(), {
|
||||
...withError('Failed loading Edge stack'),
|
||||
select,
|
||||
refetchInterval,
|
||||
|
|
|
@ -9,22 +9,44 @@ import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
|
|||
|
||||
import { EdgeGroup } from '../edge-groups/types';
|
||||
|
||||
interface EdgeStackStatusDetails {
|
||||
Pending: boolean;
|
||||
Ok: boolean;
|
||||
Error: boolean;
|
||||
Acknowledged: boolean;
|
||||
Remove: boolean;
|
||||
RemoteUpdateSuccess: boolean;
|
||||
ImagesPulled: boolean;
|
||||
export enum StatusType {
|
||||
/** Pending represents a pending edge stack */
|
||||
Pending,
|
||||
/** DeploymentReceived represents an edge environment which received the edge stack deployment */
|
||||
DeploymentReceived,
|
||||
/** Error represents an edge environment which failed to deploy its edge stack */
|
||||
Error,
|
||||
/** Acknowledged represents an acknowledged edge stack */
|
||||
Acknowledged,
|
||||
/** Removed represents a removed edge stack */
|
||||
Removed,
|
||||
/** StatusRemoteUpdateSuccess represents a successfully updated edge stack */
|
||||
RemoteUpdateSuccess,
|
||||
/** ImagesPulled represents a successfully images-pulling */
|
||||
ImagesPulled,
|
||||
/** Running represents a running Edge stack */
|
||||
Running,
|
||||
/** Deploying represents an Edge stack which is being deployed */
|
||||
Deploying,
|
||||
/** Removing represents an Edge stack which is being removed */
|
||||
Removing,
|
||||
}
|
||||
|
||||
export type StatusType = keyof EdgeStackStatusDetails;
|
||||
export interface DeploymentStatus {
|
||||
Type: StatusType;
|
||||
Error: string;
|
||||
Time: number;
|
||||
}
|
||||
|
||||
interface EdgeStackDeploymentInfo {
|
||||
FileVersion: number;
|
||||
ConfigHash: string;
|
||||
}
|
||||
|
||||
export interface EdgeStackStatus {
|
||||
Details: EdgeStackStatusDetails;
|
||||
Error: string;
|
||||
Status: Array<DeploymentStatus>;
|
||||
EndpointID: EnvironmentId;
|
||||
DeploymentInfo?: EdgeStackDeploymentInfo;
|
||||
}
|
||||
|
||||
export enum DeploymentType {
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { DeploymentStatus } from '../types';
|
||||
|
||||
/**
|
||||
* returns the latest status object of each type
|
||||
*/
|
||||
export function uniqueStatus(statusArray: Array<DeploymentStatus> = []) {
|
||||
// keep only the last status object of each type, assume that the last status is the most recent
|
||||
return statusArray.reduce((acc, status) => {
|
||||
const index = acc.findIndex((s) => s.Type === status.Type);
|
||||
if (index === -1) {
|
||||
return [...acc, status];
|
||||
}
|
||||
|
||||
return [...acc.slice(0, index), ...acc.slice(index + 1), status];
|
||||
}, [] as Array<DeploymentStatus>);
|
||||
}
|
|
@ -3,6 +3,7 @@ package composeplugin
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
@ -160,7 +161,11 @@ func (wrapper *PluginWrapper) command(command composeCommand, options libstack.O
|
|||
Err(err).
|
||||
Msg("docker compose command failed")
|
||||
|
||||
return nil, errors.New(errOutput)
|
||||
if errOutput != "" {
|
||||
return nil, errors.New(errOutput)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("docker compose command failed: %w", err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
|
|
|
@ -2,7 +2,6 @@ package composeplugin
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
@ -16,9 +15,9 @@ import (
|
|||
)
|
||||
|
||||
func checkPrerequisites(t *testing.T) {
|
||||
if _, err := os.Stat("docker-compose"); errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("docker-compose binary not found, please run download.sh and re-run this test suite")
|
||||
}
|
||||
// if _, err := os.Stat("docker-compose"); errors.Is(err, os.ErrNotExist) {
|
||||
// t.Fatal("docker-compose binary not found, please run download.sh and re-run this test suite")
|
||||
// }
|
||||
}
|
||||
|
||||
func setup(t *testing.T) libstack.Deployer {
|
||||
|
@ -118,7 +117,11 @@ func createFile(dir, fileName, content string) (string, error) {
|
|||
return "", err
|
||||
}
|
||||
|
||||
f.WriteString(content)
|
||||
_, err = f.WriteString(content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
f.Close()
|
||||
|
||||
return filePath, nil
|
||||
|
|
|
@ -0,0 +1,171 @@
|
|||
package composeplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libstack"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type publisher struct {
|
||||
URL string
|
||||
TargetPort int
|
||||
PublishedPort int
|
||||
Protocol string
|
||||
}
|
||||
|
||||
type service struct {
|
||||
ID string
|
||||
Name string
|
||||
Image string
|
||||
Command string
|
||||
Project string
|
||||
Service string
|
||||
Created int64
|
||||
State string
|
||||
Status string
|
||||
Health string
|
||||
ExitCode int
|
||||
Publishers []publisher
|
||||
}
|
||||
|
||||
// docker container state can be one of "created", "running", "paused", "restarting", "removing", "exited", or "dead"
|
||||
func getServiceStatus(service service) (libstack.Status, string) {
|
||||
log.Debug().
|
||||
Str("service", service.Name).
|
||||
Str("state", service.State).
|
||||
Int("exitCode", service.ExitCode).
|
||||
Msg("getServiceStatus")
|
||||
|
||||
switch service.State {
|
||||
case "created", "restarting", "paused":
|
||||
return libstack.StatusStarting, ""
|
||||
case "running":
|
||||
return libstack.StatusRunning, ""
|
||||
case "removing":
|
||||
return libstack.StatusRemoving, ""
|
||||
case "exited", "dead":
|
||||
if service.ExitCode != 0 {
|
||||
return libstack.StatusError, fmt.Sprintf("service %s exited with code %d", service.Name, service.ExitCode)
|
||||
}
|
||||
|
||||
return libstack.StatusRemoved, ""
|
||||
default:
|
||||
return libstack.StatusUnknown, ""
|
||||
}
|
||||
}
|
||||
|
||||
func aggregateStatuses(services []service) (libstack.Status, string) {
|
||||
servicesCount := len(services)
|
||||
|
||||
if servicesCount == 0 {
|
||||
log.Debug().
|
||||
Msg("no services found")
|
||||
|
||||
return libstack.StatusRemoved, ""
|
||||
}
|
||||
|
||||
statusCounts := make(map[libstack.Status]int)
|
||||
errorMessage := ""
|
||||
for _, service := range services {
|
||||
status, serviceError := getServiceStatus(service)
|
||||
if serviceError != "" {
|
||||
errorMessage = serviceError
|
||||
}
|
||||
statusCounts[status]++
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Interface("statusCounts", statusCounts).
|
||||
Str("errorMessage", errorMessage).
|
||||
Msg("check_status")
|
||||
|
||||
switch {
|
||||
case errorMessage != "":
|
||||
return libstack.StatusError, errorMessage
|
||||
case statusCounts[libstack.StatusStarting] > 0:
|
||||
return libstack.StatusStarting, ""
|
||||
case statusCounts[libstack.StatusRemoving] > 0:
|
||||
return libstack.StatusRemoving, ""
|
||||
case statusCounts[libstack.StatusRunning] == servicesCount:
|
||||
return libstack.StatusRunning, ""
|
||||
case statusCounts[libstack.StatusStopped] == servicesCount:
|
||||
return libstack.StatusStopped, ""
|
||||
case statusCounts[libstack.StatusRemoved] == servicesCount:
|
||||
return libstack.StatusRemoved, ""
|
||||
default:
|
||||
return libstack.StatusUnknown, ""
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (wrapper *PluginWrapper) WaitForStatus(ctx context.Context, name string, status libstack.Status) <-chan string {
|
||||
errorMessageCh := make(chan string)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
errorMessageCh <- fmt.Sprintf("failed to wait for status: %s", ctx.Err().Error())
|
||||
default:
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
output, err := wrapper.command(newCommand([]string{"ps", "-a", "--format", "json"}, nil), libstack.Options{
|
||||
ProjectName: name,
|
||||
})
|
||||
if len(output) == 0 {
|
||||
log.Debug().
|
||||
Str("project_name", name).
|
||||
Msg("no output from docker compose ps")
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Str("project_name", name).
|
||||
Err(err).
|
||||
Msg("error from docker compose ps")
|
||||
continue
|
||||
}
|
||||
|
||||
var services []service
|
||||
err = json.Unmarshal(output, &services)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Str("project_name", name).
|
||||
Err(err).
|
||||
Msg("failed to parse docker compose output")
|
||||
continue
|
||||
}
|
||||
|
||||
if len(services) == 0 && status == libstack.StatusRemoved {
|
||||
errorMessageCh <- ""
|
||||
return
|
||||
}
|
||||
|
||||
aggregateStatus, errorMessage := aggregateStatuses(services)
|
||||
if aggregateStatus == status {
|
||||
errorMessageCh <- ""
|
||||
return
|
||||
}
|
||||
|
||||
if errorMessage != "" {
|
||||
errorMessageCh <- errorMessage
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("project_name", name).
|
||||
Str("status", string(aggregateStatus)).
|
||||
Msg("waiting for status")
|
||||
|
||||
}
|
||||
}()
|
||||
|
||||
return errorMessageCh
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
package composeplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libstack"
|
||||
)
|
||||
|
||||
/*
|
||||
|
||||
1. starting = docker compose file that runs several services, one of them should be with status starting
|
||||
2. running = docker compose file that runs successfully and returns status running
|
||||
3. removing = run docker compose config, remove the stack, and return removing status
|
||||
4. failed = run a valid docker compose file, but one of the services should fail to start (so "docker compose up" should run successfully, but one of the services should do something like `CMD ["exit", "1"]
|
||||
5. removed = remove a compose stack and return status removed
|
||||
|
||||
*/
|
||||
|
||||
func ensureIntegrationTest(t *testing.T) {
|
||||
if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
|
||||
t.Skip("skip an integration test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeProjectStatus(t *testing.T) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
testCases := []struct {
|
||||
TestName string
|
||||
ComposeFile string
|
||||
ExpectedStatus libstack.Status
|
||||
ExpectedStatusMessage bool
|
||||
}{
|
||||
|
||||
{
|
||||
TestName: "running",
|
||||
ComposeFile: "status_test_files/running.yml",
|
||||
ExpectedStatus: libstack.StatusRunning,
|
||||
},
|
||||
|
||||
{
|
||||
TestName: "failed",
|
||||
ComposeFile: "status_test_files/failed.yml",
|
||||
ExpectedStatus: libstack.StatusError,
|
||||
ExpectedStatusMessage: true,
|
||||
},
|
||||
}
|
||||
|
||||
w := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.TestName, func(t *testing.T) {
|
||||
projectName := testCase.TestName
|
||||
err := w.Deploy(ctx, []string{testCase.ComposeFile}, libstack.DeployOptions{
|
||||
Options: libstack.Options{
|
||||
ProjectName: projectName,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("[test: %s] Failed to deploy compose file: %v", testCase.TestName, err)
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
status, statusMessage, err := waitForStatus(w, ctx, projectName, libstack.StatusRunning)
|
||||
if err != nil {
|
||||
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
|
||||
}
|
||||
|
||||
if status != testCase.ExpectedStatus {
|
||||
t.Fatalf("[test: %s] Expected status: %s, got: %s", testCase.TestName, testCase.ExpectedStatus, status)
|
||||
}
|
||||
|
||||
if testCase.ExpectedStatusMessage && statusMessage == "" {
|
||||
t.Fatalf("[test: %s] Expected status message but got empty", testCase.TestName)
|
||||
}
|
||||
|
||||
err = w.Remove(ctx, projectName, nil, libstack.Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("[test: %s] Failed to remove compose project: %v", testCase.TestName, err)
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Second)
|
||||
|
||||
status, statusMessage, err = waitForStatus(w, ctx, projectName, libstack.StatusRemoved)
|
||||
if err != nil {
|
||||
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
|
||||
}
|
||||
|
||||
if status != libstack.StatusRemoved {
|
||||
t.Fatalf("[test: %s] Expected stack to be removed, got %s", testCase.TestName, status)
|
||||
}
|
||||
|
||||
if statusMessage != "" {
|
||||
t.Fatalf("[test: %s] Expected empty status message: %s, got: %s", "", testCase.TestName, statusMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func waitForStatus(deployer libstack.Deployer, ctx context.Context, stackName string, requiredStatus libstack.Status) (libstack.Status, string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
statusCh := deployer.WaitForStatus(ctx, stackName, requiredStatus)
|
||||
result := <-statusCh
|
||||
if result == "" {
|
||||
return requiredStatus, "", nil
|
||||
}
|
||||
|
||||
return libstack.StatusError, result, nil
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
version: '3'
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
failing-service:
|
||||
image: busybox
|
||||
command: ["false"]
|
|
@ -0,0 +1,4 @@
|
|||
version: '3'
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
|
@ -13,8 +13,21 @@ type Deployer interface {
|
|||
Remove(ctx context.Context, projectName string, filePaths []string, options Options) error
|
||||
Pull(ctx context.Context, filePaths []string, options Options) error
|
||||
Validate(ctx context.Context, filePaths []string, options Options) error
|
||||
WaitForStatus(ctx context.Context, name string, status Status) <-chan string
|
||||
}
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusUnknown Status = "unknown"
|
||||
StatusStarting Status = "starting"
|
||||
StatusRunning Status = "running"
|
||||
StatusStopped Status = "stopped"
|
||||
StatusError Status = "error"
|
||||
StatusRemoving Status = "removing"
|
||||
StatusRemoved Status = "removed"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
WorkingDir string
|
||||
Host string
|
||||
|
|
Loading…
Reference in New Issue