feat(edge/stacks): increase status transparency [EE-5554] (#9094)

pull/9209/head
Chaim Lev-Ari 1 year ago committed by GitHub
parent db61fb149b
commit 0bcb57568c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

2
.gitignore vendored

@ -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
return nil, handler.handlerDBErr(err, "Unable to persist the stack changes inside the database")
}
} else {
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
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
err = tx.EdgeStack().UpdateEdgeStack(stackID, stack)
if err != nil {
return nil, handler.handlerDBErr(err, "Unable to persist the stack changes inside the database")
}
}
stack.Status[payload.EndpointID] = portainer.EdgeStackStatus{
Details: details,
Error: payload.Error,
EndpointID: payload.EndpointID,
}
return stack, nil
}
err = tx.EdgeStack().UpdateEdgeStack(stackID, stack)
}
if err != nil {
return nil, handler.handlerDBErr(err, "Unable to persist the stack changes inside the database")
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{},
}
}
return stack, nil
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 lastStatus.Type != *payload.Status {
t.Fatalf("expected EdgeStackStatusType %d, found %d", *payload.Status, lastStatus.Type)
}
if data.Status[endpoint.ID].EndpointID != payload.EndpointID {
t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, data.Status[endpoint.ID].EndpointID)
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)
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,
edgeStacksService,
edgestacks.NewService(store),
)
tmpDir := t.TempDir()
fs, err := filesystem.NewService(tmpDir, "")
if err != nil {
t.Fatal(err)
}
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
}
_, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, []byte(payload.StackFileContent))
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)
}
stack.NumDeployments = len(relatedEndpointIds)
manifestPath = tempManifestPath
}
stack.UseManifestNamespaces = payload.UseManifestNamespaces
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
if manifestPath == "" {
manifestPath = filesystem.ManifestFileDefaultName
}
stack.EdgeGroups = groupsIds
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, []byte(payload.StackFileContent))
if payload.UpdateVersion {
err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds)
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');
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');
}
if (details.ImagesPulled) {
labels.push('Images pre-pulled');
return _.uniq(labels).join(', ');
}
function TargetVersionCell({
row,
getValue,
}: CellContext<EdgeStackEnvironment, string>) {
const value = getValue();
if (!value) {
return '';
}
if (details.Ok) {
labels.push('Deployed');
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() || '';
}
if (details.Error) {
labels.push('Failed');
function DeployedVersionCell({
row,
getValue,
}: CellContext<EdgeStackEnvironment, string>) {
const value = getValue();
if (!value || value === '0') {
return (
<div>
<Icon icon={UpdatesAvailable} className="!mr-2" />
</div>
);
}
if (!labels.length) {
labels.push('Pending');
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',
})}
/>
return labels.join(', ');
<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…
Cancel
Save