Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46bff06c6 | ||
|
|
5d311031e3 | ||
|
|
99de11894c |
@@ -49,7 +49,6 @@ import (
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
"github.com/portainer/portainer/pkg/libstack"
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
@@ -166,26 +165,6 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
|
||||
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
|
||||
}
|
||||
|
||||
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
||||
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed creating compose manager")
|
||||
}
|
||||
|
||||
return composeWrapper
|
||||
}
|
||||
|
||||
func initSwarmStackManager(
|
||||
assetsPath string,
|
||||
configPath string,
|
||||
signatureService portainer.DigitalSignatureService,
|
||||
fileService portainer.FileService,
|
||||
reverseTunnelService portainer.ReverseTunnelService,
|
||||
dataStore dataservices.DataStore,
|
||||
) (portainer.SwarmStackManager, error) {
|
||||
return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService, dataStore)
|
||||
}
|
||||
|
||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
|
||||
}
|
||||
@@ -435,9 +414,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
composeDeployer := compose.NewComposeDeployer()
|
||||
|
||||
composeStackManager := initComposeStackManager(composeDeployer, proxyManager)
|
||||
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager, dataStore)
|
||||
|
||||
swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
|
||||
swarmStackManager, err := exec.NewSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
|
||||
}
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.24.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.24.1",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -942,7 +942,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.24.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.24.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -9,27 +9,32 @@ import (
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/libstack"
|
||||
|
||||
"github.com/docker/cli/cli/config/types"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ComposeStackManager is a wrapper for docker-compose binary
|
||||
type ComposeStackManager struct {
|
||||
deployer libstack.Deployer
|
||||
proxyManager *proxy.Manager
|
||||
dataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil
|
||||
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
|
||||
// NewComposeStackManager returns a Compose stack manager
|
||||
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager, dataStore dataservices.DataStore) *ComposeStackManager {
|
||||
return &ComposeStackManager{
|
||||
deployer: deployer,
|
||||
proxyManager: proxyManager,
|
||||
}, nil
|
||||
dataStore: dataStore,
|
||||
}
|
||||
}
|
||||
|
||||
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
|
||||
@@ -60,7 +65,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
|
||||
EnvFilePath: envFilePath,
|
||||
Host: url,
|
||||
ProjectName: stack.Name,
|
||||
Registries: portainerRegistriesToAuthConfigs(options.Registries),
|
||||
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
|
||||
},
|
||||
ForceRecreate: options.ForceRecreate,
|
||||
AbortOnContainerExit: options.AbortOnContainerExit,
|
||||
@@ -91,7 +96,7 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
|
||||
EnvFilePath: envFilePath,
|
||||
Host: url,
|
||||
ProjectName: stack.Name,
|
||||
Registries: portainerRegistriesToAuthConfigs(options.Registries),
|
||||
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
|
||||
},
|
||||
Remove: options.Remove,
|
||||
Args: options.Args,
|
||||
@@ -140,7 +145,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
|
||||
EnvFilePath: envFilePath,
|
||||
Host: url,
|
||||
ProjectName: stack.Name,
|
||||
Registries: portainerRegistriesToAuthConfigs(options.Registries),
|
||||
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
|
||||
})
|
||||
return errors.Wrap(err, "failed to pull images of the stack")
|
||||
}
|
||||
@@ -221,16 +226,48 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func portainerRegistriesToAuthConfigs(registries []portainer.Registry) []types.AuthConfig {
|
||||
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
|
||||
var authConfigs []types.AuthConfig
|
||||
|
||||
for _, r := range registries {
|
||||
authConfigs = append(authConfigs, types.AuthConfig{
|
||||
ac := types.AuthConfig{
|
||||
Username: r.Username,
|
||||
Password: r.Password,
|
||||
ServerAddress: r.URL,
|
||||
})
|
||||
}
|
||||
|
||||
if r.Authentication {
|
||||
var err error
|
||||
|
||||
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
authConfigs = append(authConfigs, ac)
|
||||
}
|
||||
|
||||
return authConfigs
|
||||
}
|
||||
|
||||
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
|
||||
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("RegistryName", registry.Name).
|
||||
Msg("Failed to validate registry token. Skip logging with this registry.")
|
||||
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
username, password, err := registryutils.GetRegEffectiveCredential(registry)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("RegistryName", registry.Name).
|
||||
Msg("Failed to get effective credential. Skip logging with this registry.")
|
||||
}
|
||||
|
||||
return username, password, err
|
||||
}
|
||||
|
||||
@@ -48,10 +48,7 @@ func Test_UpAndDown(t *testing.T) {
|
||||
|
||||
deployer := compose.NewComposeDeployer()
|
||||
|
||||
w, err := NewComposeStackManager(deployer, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed creating manager: %s", err)
|
||||
}
|
||||
w := NewComposeStackManager(deployer, nil, nil)
|
||||
|
||||
ctx := context.TODO()
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -62,22 +61,8 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
|
||||
|
||||
for _, registry := range registries {
|
||||
if registry.Authentication {
|
||||
if err := registryutils.EnsureRegTokenValid(manager.dataStore, ®istry); err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("RegistryName", registry.Name).
|
||||
Msg("Failed to validate registry token. Skip logging with this registry.")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
username, password, err := registryutils.GetRegEffectiveCredential(®istry)
|
||||
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, ®istry)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("RegistryName", registry.Name).
|
||||
Msg("Failed to get effective credential. Skip logging with this registry.")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.24.0
|
||||
// @version 2.24.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -14,47 +14,51 @@ func isRegTokenValid(registry *portainer.Registry) (valid bool) {
|
||||
return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix()
|
||||
}
|
||||
|
||||
func doGetRegToken(dataStore dataservices.DataStore, registry *portainer.Registry) (err error) {
|
||||
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
|
||||
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
|
||||
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken()
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
registry.AccessToken = *accessToken
|
||||
registry.AccessTokenExpiry = expiryAt.Unix()
|
||||
|
||||
err = dataStore.Registry().Update(registry.ID, registry)
|
||||
|
||||
return
|
||||
return tx.Registry().Update(registry.ID, registry)
|
||||
}
|
||||
|
||||
func parseRegToken(registry *portainer.Registry) (username, password string, err error) {
|
||||
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
|
||||
return ecrClient.ParseAuthorizationToken(registry.AccessToken)
|
||||
return ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
|
||||
ParseAuthorizationToken(registry.AccessToken)
|
||||
}
|
||||
|
||||
func EnsureRegTokenValid(dataStore dataservices.DataStore, registry *portainer.Registry) (err error) {
|
||||
if registry.Type == portainer.EcrRegistry {
|
||||
if isRegTokenValid(registry) {
|
||||
log.Debug().Msg("current ECR token is still valid")
|
||||
} else {
|
||||
err = doGetRegToken(dataStore, registry)
|
||||
if err != nil {
|
||||
log.Debug().Msg("refresh ECR token")
|
||||
}
|
||||
}
|
||||
func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
|
||||
if registry.Type != portainer.EcrRegistry {
|
||||
return nil
|
||||
}
|
||||
|
||||
return
|
||||
if isRegTokenValid(registry) {
|
||||
log.Debug().Msg("current ECR token is still valid")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := doGetRegToken(tx, registry); err != nil {
|
||||
log.Debug().Msg("refresh ECR token")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetRegEffectiveCredential(registry *portainer.Registry) (username, password string, err error) {
|
||||
username = registry.Username
|
||||
password = registry.Password
|
||||
|
||||
if registry.Type == portainer.EcrRegistry {
|
||||
username, password, err = parseRegToken(registry)
|
||||
} else {
|
||||
username = registry.Username
|
||||
password = registry.Password
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1617,7 +1617,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.24.0"
|
||||
APIVersion = "2.24.1"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
|
||||
@@ -5,82 +5,110 @@
|
||||
library css for buttons is overriden by `.widget .widget-body button`
|
||||
so we have to force margin: 0
|
||||
*/
|
||||
.react-datetime-picker .react-calendar button {
|
||||
.react-daterange-picker__calendar .react-calendar button {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/*
|
||||
Extending Calendar.css from react-datetime-picker
|
||||
Extending Calendar.css from react-daterange-picker__calendar
|
||||
*/
|
||||
.react-datetime-picker .react-calendar {
|
||||
.react-daterange-picker__calendar .react-calendar {
|
||||
background: var(--bg-calendar-color);
|
||||
color: var(--text-main-color);
|
||||
}
|
||||
|
||||
/* calendar nav buttons */
|
||||
.react-datetime-picker .react-calendar__navigation button:disabled {
|
||||
background-color: var(--bg-calendar-color);
|
||||
.react-daterange-picker__calendar .react-calendar__navigation button:disabled {
|
||||
background: var(--bg-calendar-color);
|
||||
@apply opacity-60;
|
||||
@apply brightness-95 th-dark:brightness-110;
|
||||
}
|
||||
.react-datetime-picker .react-calendar__navigation button:enabled:hover,
|
||||
.react-datetime-picker .react-calendar__navigation button:enabled:focus {
|
||||
background-color: var(--bg-daterangepicker-color);
|
||||
.react-daterange-picker__calendar .react-calendar__navigation button:enabled:hover,
|
||||
.react-daterange-picker__calendar .react-calendar__navigation button:enabled:focus {
|
||||
background: var(--bg-daterangepicker-color);
|
||||
}
|
||||
|
||||
/* date tile */
|
||||
.react-datetime-picker .react-calendar__tile:disabled {
|
||||
background-color: var(--bg-calendar-color);
|
||||
.react-daterange-picker__calendar .react-calendar__tile:disabled {
|
||||
background: var(--bg-calendar-color);
|
||||
@apply opacity-60;
|
||||
@apply brightness-95 th-dark:brightness-110;
|
||||
}
|
||||
.react-datetime-picker .react-calendar__tile:enabled:hover,
|
||||
.react-datetime-picker .react-calendar__tile:enabled:focus {
|
||||
background-color: var(--bg-daterangepicker-hover);
|
||||
.react-daterange-picker__calendar .react-calendar__tile:enabled:hover,
|
||||
.react-daterange-picker__calendar .react-calendar__tile:enabled:focus {
|
||||
background: var(--bg-daterangepicker-hover);
|
||||
}
|
||||
|
||||
/* today's date tile */
|
||||
.react-datetime-picker .react-calendar__tile--now {
|
||||
/* use background color to avoid white on yellow in dark/high contrast modes */
|
||||
.react-daterange-picker__calendar .react-calendar__tile--now {
|
||||
@apply th-highcontrast:text-[color:var(--bg-calendar-color)] th-dark:text-[color:var(--bg-calendar-color)];
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
.react-datetime-picker .react-calendar__tile--now:enabled:hover,
|
||||
.react-datetime-picker .react-calendar__tile--now:enabled:focus {
|
||||
.react-daterange-picker__calendar .react-calendar__tile--now:enabled:hover,
|
||||
.react-daterange-picker__calendar .react-calendar__tile--now:enabled:focus {
|
||||
background: var(--bg-daterangepicker-hover);
|
||||
color: var(--text-daterangepicker-hover);
|
||||
}
|
||||
|
||||
/* probably date tile in range */
|
||||
.react-datetime-picker .react-calendar__tile--hasActive {
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hasActive {
|
||||
background: var(--bg-daterangepicker-end-date);
|
||||
color: var(--text-daterangepicker-end-date);
|
||||
}
|
||||
.react-datetime-picker .react-calendar__tile--hasActive:enabled:hover,
|
||||
.react-datetime-picker .react-calendar__tile--hasActive:enabled:focus {
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:hover,
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:focus {
|
||||
background: var(--bg-daterangepicker-hover);
|
||||
color: var(--text-daterangepicker-hover);
|
||||
}
|
||||
|
||||
/* selected date tile */
|
||||
.react-datetime-picker .react-calendar__tile--active {
|
||||
background: var(--bg-daterangepicker-active);
|
||||
color: var(--text-daterangepicker-active);
|
||||
}
|
||||
.react-datetime-picker .react-calendar__tile--active:enabled:hover,
|
||||
.react-datetime-picker .react-calendar__tile--active:enabled:focus {
|
||||
.react-daterange-picker__calendar .react-calendar__tile--active:enabled:hover,
|
||||
.react-daterange-picker__calendar .react-calendar__tile--active:enabled:focus {
|
||||
background: var(--bg-daterangepicker-hover);
|
||||
color: var(--text-daterangepicker-hover);
|
||||
}
|
||||
|
||||
.react-daterange-picker__calendar
|
||||
.react-calendar__month-view__days__day:hover:not(.react-daterange-picker__calendar .react-calendar__tile--hoverEnd):not(
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hoverStart
|
||||
):not(.react-calendar__tile--active) {
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* on range select hover */
|
||||
.react-datetime-picker .react-calendar--selectRange .react-calendar__tile--hover {
|
||||
background-color: var(--bg-daterangepicker-in-range);
|
||||
.react-daterange-picker__calendar .react-calendar--selectRange .react-calendar__tile--hover {
|
||||
background: var(--bg-daterangepicker-in-range);
|
||||
color: var(--text-daterangepicker-in-range);
|
||||
}
|
||||
|
||||
/*
|
||||
Extending DateTimePicker.css from react-datetime-picker
|
||||
Extending DateTimePicker.css from react-daterange-picker__calendar
|
||||
*/
|
||||
.react-datetime-picker .react-datetime-picker--disabled {
|
||||
.react-daterange-picker__calendar .react-daterange-picker__calendar--disabled {
|
||||
@apply opacity-40;
|
||||
}
|
||||
|
||||
/* selected date tile */
|
||||
.react-daterange-picker__calendar .react-calendar__tile--active {
|
||||
background: var(--bg-daterangepicker-active) !important;
|
||||
color: var(--text-daterangepicker-active) !important;
|
||||
}
|
||||
|
||||
.react-daterange-picker__calendar .react-calendar__tile--rangeStart:not(.react-calendar__tile--rangeEnd),
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hoverStart {
|
||||
border-top-left-radius: 0.25rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.react-daterange-picker__calendar .react-calendar__tile--rangeEnd:not(.react-calendar__tile--rangeStart),
|
||||
.react-daterange-picker__calendar .react-calendar__tile--hoverEnd {
|
||||
border-top-right-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.react-daterange-picker__calendar .react-calendar__month-view__days__day--weekend {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.react-calendar__tile--active.react-calendar__month-view__days__day--weekend {
|
||||
color: var(--text-daterangepicker-active);
|
||||
}
|
||||
|
||||
@@ -14,13 +14,8 @@ type StringPortBinding = {
|
||||
containerPort: number;
|
||||
};
|
||||
|
||||
type NumericPortBinding = {
|
||||
hostPort: number;
|
||||
protocol: Protocol;
|
||||
containerPort: number;
|
||||
};
|
||||
|
||||
type RangePortBinding = {
|
||||
hostIp: string;
|
||||
hostPort: Range;
|
||||
protocol: Protocol;
|
||||
containerPort: Range;
|
||||
@@ -42,9 +37,7 @@ export function toViewModel(portBindings: PortMap): Values {
|
||||
return value === 'tcp' || value === 'udp';
|
||||
}
|
||||
|
||||
function parsePorts(
|
||||
portBindings: PortMap
|
||||
): Array<StringPortBinding | NumericPortBinding> {
|
||||
function parsePorts(portBindings: PortMap): Array<StringPortBinding> {
|
||||
return Object.entries(portBindings).flatMap(([key, bindings]) => {
|
||||
const [containerPort, protocol] = key.split('/');
|
||||
|
||||
@@ -63,15 +56,24 @@ export function toViewModel(portBindings: PortMap): Values {
|
||||
}
|
||||
|
||||
return bindings.map((binding) => {
|
||||
let port = '';
|
||||
if (binding.HostPort) {
|
||||
port = binding.HostPort;
|
||||
}
|
||||
if (binding.HostIp) {
|
||||
port = `${binding.HostIp}:${port}`;
|
||||
}
|
||||
|
||||
if (binding.HostPort?.includes('-')) {
|
||||
// Range port
|
||||
return {
|
||||
hostPort: binding.HostPort,
|
||||
hostPort: port,
|
||||
protocol,
|
||||
containerPort: containerPortNumber,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hostPort: parseInt(binding.HostPort || '0', 10),
|
||||
hostPort: port,
|
||||
protocol,
|
||||
containerPort: containerPortNumber,
|
||||
};
|
||||
@@ -79,9 +81,9 @@ export function toViewModel(portBindings: PortMap): Values {
|
||||
});
|
||||
}
|
||||
|
||||
function sortPorts(ports: Array<StringPortBinding | NumericPortBinding>) {
|
||||
const rangePorts = ports.filter(isStringPortBinding);
|
||||
const nonRangePorts = ports.filter(isNumericPortBinding);
|
||||
function sortPorts(ports: Array<StringPortBinding>) {
|
||||
const rangePorts = ports.filter(isRangePortBinding);
|
||||
const nonRangePorts = ports.filter((port) => !isRangePortBinding(port));
|
||||
|
||||
return {
|
||||
rangePorts,
|
||||
@@ -93,27 +95,40 @@ export function toViewModel(portBindings: PortMap): Values {
|
||||
};
|
||||
}
|
||||
|
||||
function combinePorts(ports: Array<NumericPortBinding>) {
|
||||
function combinePorts(ports: Array<StringPortBinding>) {
|
||||
return ports
|
||||
.reduce((acc, port) => {
|
||||
let hostIp = '';
|
||||
let hostPort = 0;
|
||||
if (port.hostPort.includes(':')) {
|
||||
const [ipStr, portStr] = port.hostPort.split(':');
|
||||
hostIp = ipStr;
|
||||
hostPort = parseInt(portStr || '0', 10);
|
||||
} else {
|
||||
hostPort = parseInt(port.hostPort || '0', 10);
|
||||
}
|
||||
|
||||
const lastPort = acc[acc.length - 1];
|
||||
if (
|
||||
lastPort &&
|
||||
lastPort.hostIp === hostIp &&
|
||||
lastPort.containerPort.end === port.containerPort - 1 &&
|
||||
lastPort.hostPort.end === port.hostPort - 1 &&
|
||||
lastPort.hostPort.end === hostPort - 1 &&
|
||||
lastPort.protocol === port.protocol
|
||||
) {
|
||||
lastPort.hostIp = hostIp;
|
||||
lastPort.containerPort.end = port.containerPort;
|
||||
lastPort.hostPort.end = port.hostPort;
|
||||
lastPort.hostPort.end = hostPort;
|
||||
return acc;
|
||||
}
|
||||
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
hostIp,
|
||||
hostPort: {
|
||||
start: port.hostPort,
|
||||
end: port.hostPort,
|
||||
start: hostPort,
|
||||
end: hostPort,
|
||||
},
|
||||
containerPort: {
|
||||
start: port.containerPort,
|
||||
@@ -123,34 +138,32 @@ export function toViewModel(portBindings: PortMap): Values {
|
||||
},
|
||||
];
|
||||
}, [] as Array<RangePortBinding>)
|
||||
.map(({ protocol, containerPort, hostPort }) => ({
|
||||
hostPort: getRange(hostPort.start, hostPort.end),
|
||||
.map(({ protocol, containerPort, hostPort, hostIp }) => ({
|
||||
hostPort: getRange(hostPort.start, hostPort.end, hostIp),
|
||||
containerPort: getRange(containerPort.start, containerPort.end),
|
||||
protocol,
|
||||
}));
|
||||
|
||||
function getRange(start: number, end: number): string {
|
||||
function getRange(start: number, end: number, hostIp?: string): string {
|
||||
if (start === end) {
|
||||
if (start === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (hostIp) {
|
||||
return `${hostIp}:${start}`;
|
||||
}
|
||||
return start.toString();
|
||||
}
|
||||
|
||||
if (hostIp) {
|
||||
return `${hostIp}:${start}-${end}`;
|
||||
}
|
||||
return `${start}-${end}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isNumericPortBinding(
|
||||
port: StringPortBinding | NumericPortBinding
|
||||
): port is NumericPortBinding {
|
||||
return port.hostPort !== 'string';
|
||||
}
|
||||
|
||||
function isStringPortBinding(
|
||||
port: StringPortBinding | NumericPortBinding
|
||||
): port is StringPortBinding {
|
||||
return port.hostPort === 'string';
|
||||
function isRangePortBinding(port: StringPortBinding): boolean {
|
||||
return port.hostPort.includes('-');
|
||||
}
|
||||
|
||||
@@ -57,10 +57,15 @@ export async function buildImageFromDockerfileContentAndFiles(
|
||||
const dockerfile = new Blob([content], { type: 'text/plain' });
|
||||
const uploadFiles = [dockerfile, ...files];
|
||||
|
||||
const formData = new FormData();
|
||||
uploadFiles.forEach((file, index) => {
|
||||
formData.append(`file${index}`, file);
|
||||
});
|
||||
|
||||
return buildImage(
|
||||
environmentId,
|
||||
{ t: names },
|
||||
{ file: uploadFiles },
|
||||
formData,
|
||||
'multipart/form-data'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
export interface ActivityLog {
|
||||
interface BaseActivityLog {
|
||||
timestamp: number;
|
||||
action: string;
|
||||
context: string;
|
||||
id: number;
|
||||
payload: object;
|
||||
username: string;
|
||||
}
|
||||
export interface ActivityLogResponse extends BaseActivityLog {
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface ActivityLog extends BaseActivityLog {
|
||||
payload: string | object;
|
||||
}
|
||||
|
||||
export interface ActivityLogsResponse {
|
||||
logs: Array<ActivityLogResponse>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { isBE } from '../../feature-flags/feature-flags.service';
|
||||
|
||||
import { ActivityLog } from './types';
|
||||
import { ActivityLogResponse, ActivityLogsResponse } from './types';
|
||||
|
||||
export const sortKeys = ['Context', 'Action', 'Timestamp', 'Username'] as const;
|
||||
export type SortKey = (typeof sortKeys)[number];
|
||||
@@ -30,19 +30,18 @@ export function useActivityLogs(query: Query) {
|
||||
queryKey: ['activityLogs', query] as const,
|
||||
queryFn: () => fetchActivityLogs(query),
|
||||
keepPreviousData: true,
|
||||
select: (data) => ({
|
||||
...data,
|
||||
logs: decorateLogs(data.logs),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
interface ActivityLogsResponse {
|
||||
logs: Array<ActivityLog>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
async function fetchActivityLogs(query: Query): Promise<ActivityLogsResponse> {
|
||||
try {
|
||||
if (!isBE) {
|
||||
return {
|
||||
logs: [{}, {}, {}, {}, {}] as Array<ActivityLog>,
|
||||
logs: [{}, {}, {}, {}, {}] as Array<ActivityLogResponse>,
|
||||
totalCount: 5,
|
||||
};
|
||||
}
|
||||
@@ -56,3 +55,40 @@ async function fetchActivityLogs(query: Query): Promise<ActivityLogsResponse> {
|
||||
throw parseAxiosError(err, 'Failed loading user activity logs csv');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates logs with the payload parsed from base64
|
||||
*/
|
||||
function decorateLogs(logs?: ActivityLogResponse[]) {
|
||||
if (!logs || logs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return logs.map((log) => ({
|
||||
...log,
|
||||
payload: parseBase64AsObject(log.payload),
|
||||
}));
|
||||
}
|
||||
|
||||
function parseBase64AsObject(value: string): string | object {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(safeAtob(value));
|
||||
} catch (err) {
|
||||
return safeAtob(value);
|
||||
}
|
||||
}
|
||||
|
||||
function safeAtob(value: string) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return window.atob(value);
|
||||
} catch (err) {
|
||||
// If the payload is not base64 encoded, return the original value
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -9,7 +9,7 @@ require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
|
||||
github.com/aws/aws-sdk-go-v2 v1.24.1
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.16.16
|
||||
github.com/aws/aws-sdk-go-v2/service/ecr v1.14.0
|
||||
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1
|
||||
github.com/cbroglie/mustache v1.4.0
|
||||
github.com/compose-spec/compose-go/v2 v2.0.2
|
||||
github.com/containers/image/v5 v5.30.1
|
||||
|
||||
9
go.sum
9
go.sum
@@ -57,7 +57,6 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.13.0/go.mod h1:L6+ZpqHaLbAaxsqV0L4cvxZY7QupWJB4fhkf8LXvC7w=
|
||||
github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o=
|
||||
@@ -66,16 +65,14 @@ github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5g
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4/go.mod h1:XHgQ7Hz2WY2GAn//UXHofLfPXWh+s62MbMOijrg12Lw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0/go.mod h1:BsCSJHx5DnDXIrOcqB8KN1/B+hXLG/bi4Y6Vjcx/x9E=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
|
||||
github.com/aws/aws-sdk-go-v2/service/ecr v1.14.0 h1:AAZJJAENsQ4yYbnfvqPZT8Nc1YlEd5CZ4usymlC2b4U=
|
||||
github.com/aws/aws-sdk-go-v2/service/ecr v1.14.0/go.mod h1:a3WUi3JjM3MFtIYenSYPJ7UZPXsw7U7vzebnynxucks=
|
||||
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1 h1:zqXEIhuR7RcHob2gxB/Xf1X4XuMS0vapn7xr+wCPrpg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1/go.mod h1:+rWYJfms9p+D/wUN599tx3FtWvxoXCP25b8Porlrxcc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4=
|
||||
@@ -86,7 +83,6 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U=
|
||||
github.com/aws/smithy-go v1.10.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
|
||||
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
|
||||
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
|
||||
github.com/beorn7/perks v0.0.0-20150223135152-b965b613227f/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
@@ -315,7 +311,6 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.24.0",
|
||||
"version": "2.24.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
@@ -70,32 +70,24 @@ func withComposeService(
|
||||
return withCli(ctx, options, func(ctx context.Context, cli *command.DockerCli) error {
|
||||
composeService := compose.NewComposeService(cli)
|
||||
|
||||
if len(filePaths) == 0 {
|
||||
return composeFn(composeService, nil)
|
||||
}
|
||||
|
||||
env, err := parseEnvironment(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configDetails := types.ConfigDetails{
|
||||
WorkingDir: options.WorkingDir,
|
||||
Environment: make(map[string]string),
|
||||
Environment: env,
|
||||
WorkingDir: filepath.Dir(filePaths[0]),
|
||||
}
|
||||
|
||||
for _, p := range filePaths {
|
||||
configDetails.ConfigFiles = append(configDetails.ConfigFiles, types.ConfigFile{Filename: p})
|
||||
}
|
||||
|
||||
envFile := make(map[string]string)
|
||||
|
||||
if options.EnvFilePath != "" {
|
||||
env, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath})
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get the environment from the env file: %w", err)
|
||||
}
|
||||
|
||||
maps.Copy(envFile, env)
|
||||
|
||||
configDetails.Environment = env
|
||||
}
|
||||
|
||||
if len(configDetails.ConfigFiles) == 0 {
|
||||
return composeFn(composeService, nil)
|
||||
}
|
||||
|
||||
project, err := loader.LoadWithContext(ctx, configDetails,
|
||||
func(o *loader.Options) {
|
||||
o.SkipResolveEnvironment = true
|
||||
@@ -110,21 +102,20 @@ func withComposeService(
|
||||
return fmt.Errorf("failed to load the compose file: %w", err)
|
||||
}
|
||||
|
||||
if options.EnvFilePath != "" {
|
||||
// Work around compose path handling
|
||||
for i, service := range project.Services {
|
||||
for j, envFile := range service.EnvFiles {
|
||||
if !filepath.IsAbs(envFile.Path) {
|
||||
project.Services[i].EnvFiles[j].Path = filepath.Join(project.WorkingDir, envFile.Path)
|
||||
}
|
||||
// Work around compose path handling
|
||||
for i, service := range project.Services {
|
||||
for j, envFile := range service.EnvFiles {
|
||||
if !filepath.IsAbs(envFile.Path) {
|
||||
project.Services[i].EnvFiles[j].Path = filepath.Join(configDetails.WorkingDir, envFile.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
|
||||
project = p
|
||||
} else {
|
||||
return fmt.Errorf("failed to resolve services environment: %w", err)
|
||||
}
|
||||
// Set the services environment variables
|
||||
if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
|
||||
project = p
|
||||
} else {
|
||||
return fmt.Errorf("failed to resolve services environment: %w", err)
|
||||
}
|
||||
|
||||
return composeFn(composeService, project)
|
||||
@@ -136,6 +127,8 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option
|
||||
return withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
|
||||
addServiceLabels(project, false)
|
||||
|
||||
project = project.WithoutUnnecessaryResources()
|
||||
|
||||
var opts api.UpOptions
|
||||
if options.ForceRecreate {
|
||||
opts.Create.Recreate = api.RecreateForce
|
||||
@@ -144,6 +137,10 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option
|
||||
opts.Create.RemoveOrphans = options.RemoveOrphans
|
||||
opts.Start.CascadeStop = options.AbortOnContainerExit
|
||||
|
||||
if err := composeService.Build(ctx, project, api.BuildOptions{}); err != nil {
|
||||
return fmt.Errorf("compose build operation failed: %w", err)
|
||||
}
|
||||
|
||||
if err := composeService.Up(ctx, project, opts); err != nil {
|
||||
return fmt.Errorf("compose up operation failed: %w", err)
|
||||
}
|
||||
@@ -256,10 +253,36 @@ func addServiceLabels(project *types.Project, oneOff bool) {
|
||||
api.ProjectLabel: project.Name,
|
||||
api.ServiceLabel: s.Name,
|
||||
api.VersionLabel: api.ComposeVersion,
|
||||
api.WorkingDirLabel: "/",
|
||||
api.WorkingDirLabel: project.WorkingDir,
|
||||
api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","),
|
||||
api.OneoffLabel: oneOffLabel,
|
||||
}
|
||||
project.Services[i] = s
|
||||
}
|
||||
}
|
||||
|
||||
func parseEnvironment(options libstack.Options) (map[string]string, error) {
|
||||
env := make(map[string]string)
|
||||
|
||||
for _, envLine := range options.Env {
|
||||
e, err := dotenv.UnmarshalWithLookup(envLine, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse environment variables: %w", err)
|
||||
}
|
||||
|
||||
maps.Copy(env, e)
|
||||
}
|
||||
|
||||
if options.EnvFilePath == "" {
|
||||
return env, nil
|
||||
}
|
||||
|
||||
e, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get the environment from the env file: %w", err)
|
||||
}
|
||||
|
||||
maps.Copy(env, e)
|
||||
|
||||
return env, nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export default defineConfig({
|
||||
},
|
||||
bail: 2,
|
||||
include: ['./app/**/*.test.ts', './app/**/*.test.tsx'],
|
||||
env: {
|
||||
PORTAINER_EDITION: 'CE',
|
||||
},
|
||||
},
|
||||
plugins: [svgr({ include: /\?c$/ }), tsconfigPaths()],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user