Compare commits

..

15 Commits

Author SHA1 Message Date
claude code agent
f658d67ccb refactor(settings): extract shared parseGoDuration to drop the duplicated parser (F10)
The auto-heal floor fix copied durationPattern/unitSeconds/parseGoDurationSeconds
verbatim from AutoUpdatePanel into AutoHealPanel. Move them to a shared
SettingsView/parseGoDuration module imported by both panels — single source of
truth (mirrors the STALE_TIME dedup), so the two clients' floors can't drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:49:35 +03:00
claude code agent
be3bfd0513 fix(automation): maintainer pre-merge review — stale detection, daemon edge cases, parity (F1-F9)
F1: cap the image-status cache TTL at 5m (was 24h) — the cache is keyed by the
    LOCAL imageID, which doesn't change when upstream pushes a new image under the
    same tag, so the 24h TTL hid new images from both the badge and the auto-update
    daemon; a short TTL re-resolves the remote digest within the poll window.
F2: document that the update->rollback guard map is in-memory (restart implication).
F3: skip auto-update for an unnamed container when rollback is on (the endpoint+name
    keyed guard can't record it, so it would loop) — pure skipUnnamedForRollback + test.
F4: wrap the pre-update ContainerInspect in context.WithTimeout(endpointTimeout).
F5: document Reload() does not interrupt an in-flight tick.
F6: floor auto-heal CheckInterval at 1s (mirrors auto-update) + test.
F7: wontfix — migration is currently correct; namespace rework is out of scope.
F8: correct the misleading SSRF/AllowList comment (no filter is applied).
F9: front auto-heal interval floor + test; dedup STALE_TIME; fix invalidation comment.
Also refresh three stale '24h/long-lived cache' comments to match the 5m TTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:51:15 +03:00
claude code agent
6171806528 test(automation): cover pruneRolledBack boundary/retention (F8)
TestPruneRolledBack mirrors TestPruneRetries: seeds fresh/at-boundary/expired
rolledBack entries and asserts pruneRolledBack keeps only the fresh one
(inclusive >= cooldown boundary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:06:49 +03:00
claude code agent
922f506fe5 feat(automation): guard update→rollback loop; name Settings types; tests & doc fixes (F1-F7)
F1: record rolled-back targets per service (endpointID/containerName + remote
    digest) and skip auto-update during a 24h cooldown unless the remote digest
    changes — breaks the infinite update→rollback loop on a persistently
    unhealthy image, without blocking a genuinely new image.
F2: unit-test applyContainerUpdate dispatch/payload mapping.
F3: settings_update.go comments mention auto-heal AND auto-update.
F4: drop stale '(future M4)' TS docs; primitives are frontend-only.
F5: replace the anonymous ContainerAutomation settings struct with named
    types (identical JSON tags).
F6: drop parseEnable (duplicate of boolLabel).
F7: remove the unused gitService dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:57 +03:00
claude code agent
70f7fe5e84 Merge remote-tracking branch 'origin/feat/10-update-now' into feat/3-auto-update 2026-06-29 12:48:24 +03:00
claude code agent
cdf17d904d fix(automation): rollback robustness — transient inspect, start_period, digest images, shutdown, event order (#12 review)
F1: tolerate up to 3 consecutive health-gate inspect failures (reset on
success) before declaring an update failed, so a transient Docker API blip no
longer triggers a false rollback.

F2: detect baseCtx cancellation during the gate and abort without rolling back
or emitting update-failed (debug log only), instead of a misleading
"rollback failed" event on every shutdown mid-gate.

F3: derive the gate deadline as start + max(RollbackTimeout, StartPeriod+buffer)
via effectiveRollbackDeadline, reading the container's healthcheck StartPeriod
so a legitimately slow-starting container is not rolled back while starting.

F4: only enable the gate when the original reference is a proper tag (new
isTagReference helper); skip with a log line for digest-pinned / bare-image-id
containers that cannot be re-tagged.

F5: document the sequential-tick delay limitation of the gate poll.

F6: emit EventUpdated only after the gate confirms healthy (or immediately when
no gate is active); the rollback path emits only EventRollback, so the event
sequence is truthful.

F7: floor RollbackTimeout at 10s in backend and frontend validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:57:54 +03:00
claude code agent
32a2b7a9ae feat(automation): health-gated rollback + per-endpoint + notify hook (#12, epic #3 M5)
P0 Health-gated rollback (standalone auto-update path): capture the previous
image id + reference + healthcheck before the recreate, then poll the new
container's health over a configurable window. On healthy proceed (and only
then clean up the old image); on unhealthy/exit/timeout re-tag the old image
back onto the original reference and Recreate (no pull) to restore it, reusing
Recreate's config preservation. The decision is a pure decideRollback() helper.

P1 Per-endpoint enable: ContainerAutomationDisabled flag on Endpoint (zero value
participates, no migration churn), checked by both daemons; settable via the
endpoint update API. UI control deferred (see report).

P2 Notifier seam: minimal Notifier interface + logNotifier, emitting structured
updated/rollback/update-failed/heal-restarted events from the daemon.

Settings: RollbackOnFailure + RollbackTimeout (default 120s) added to
ContainerAutomation.AutoUpdate, wired through defaults/migration/golden,
settings_update validation, the AutoUpdatePanel and the TS types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:41:55 +03:00
claude code agent
21b5ec3e05 fix(automation): git-stack honesty + ECR registry refresh + interval floor (#11 review)
F1: Stop routing git-backed stacks through a per-tick RedeployWhenChanged for
image-only updates. The git redeploy path short-circuits when the commit is
unchanged (so an upstream-digest update never applies) yet still git-fetches
every tick. Git stacks are now detect-only in the auto-apply path; their image
update lands on the next git change or via manual "Update now". File (non-git)
stacks still force-pull-redeploy immediately. The AutoUpdatePanel text no longer
promises daemon auto-update for git/externally-managed containers.

F2: Resolve registries for the file-stack redeploy the same way the established
userless/system path (RedeployWhenChanged) does, via the new
deployments.ResolveStackRegistries: scope to the stack author's endpoint access
and RefreshAndPersistECRTokens, instead of hand-passing Registry().ReadAll().
ECR-backed stacks now auto-update with fresh tokens.

F3: Add a 1m floor for the auto-update poll interval, enforced in the settings
Validate and mirrored in the frontend validation.

F4: Thread the application shutdownCtx into NewService and use it as the base
for the heal/update job operation contexts, so shutdown cancels in-flight work.

F5: Correct the updateEndpoint comment about monitor-only badge-cache warming
(only in-scope monitor-only containers are status-checked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:24:58 +03:00
claude code agent
b3ae5f3659 feat(automation): native auto-update daemon (#11, epic #3 M4)
Add an optional periodic auto-update daemon that detects outdated container
images and applies updates, replacing the containrrr/watchtower sidecar. It
extends M1's containerautomation service/scheduler/labels infrastructure and
reuses the existing zlib image-detection engine, the standalone Recreate path
and the stack deployer.

Backend:
- api/containerautomation/autoupdate.go: scheduler job iterating Docker
  (non-edge) endpoints -> in-scope running containers -> ContainerImageStatus;
  for Outdated: standalone -> ContainerService.Recreate(pull); stack-managed ->
  one stack redeploy-with-pull per stack per tick (git via RedeployWhenChanged,
  file via the deployer directly); external compose -> detect only. Monitor-only
  containers are status-checked (warms the badge cache) but never applied.
  Overlap guard (atomic), pull/registry-auth failure -> leave running container
  untouched, conservative cleanup of the dangling old image on the Cleanup flag
  (non-forced ImageRemove only succeeds when truly unused).
- labels.go: update enable / monitor-only labels with watchtower aliases,
  InUpdateScope, IsMonitorOnly, and pure resolveContainerUpdateRouting /
  groupContainersForUpdate (Go analogue of M3's TS routing + grouping).
- service.go: run both jobs, Reload restarts/stops each per settings; NewService
  also takes ContainerService, StackDeployer and GitService.
- Settings.ContainerAutomation.AutoUpdate {Enabled, PollInterval, Scope,
  Cleanup} with fresh-install defaults and a 2.43.0 backfill (extends M1's
  migration; golden test data updated). settings handler validates + reloads.

Frontend:
- Global AutoUpdatePanel in SettingsView (enable / poll interval / scope /
  cleanup) via useUpdateSettingsMutation, plus settings TS types.
- Read-only per-container Auto-update row in the container details view
  (Docker labels are immutable at runtime), surfacing monitor-only.

Tests: Go unit tests for the update label aliases, scope, monitor-only, the
routing decision and the one-redeploy-per-stack grouping; vitest for the panel
and the per-container row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:04:09 +03:00
claude code agent
ccd5897915 fix(automation): gate stack redeploy on PortainerStackUpdate + bulk/name polish (#10 review)
F1: single-container "Update now" and bulk "Update" now require
PortainerStackUpdate when the resolved path is a stack, disabling the
action with a tooltip / skipping it rather than letting the click 403.

F2: resolveContainerUpdatePath only matches a Docker Compose stack; a
same-named swarm/kubernetes stack is treated as external.

F3: SecondaryActions no longer renders an empty ButtonGroup when all of
recreate/duplicate/update-now are hidden.

F4: bulk update reports an explicit no-op toast and counts containers vs
stacks honestly in the success summary.

F5: bulk toasts use trimmed container names (no leading slash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:42:33 +03:00
claude code agent
f7cb0f3241 feat(automation): "Update now" action (stack-aware) + bulk update (#10, epic #3 M3)
Add a discoverable per-container "Update now" action, shown only when the
image status is `outdated`, plus a bulk "Update selected" action in the
containers list.

Both manual paths share ONE apply primitive (applyContainerUpdate /
useUpdateContainerImage) that also backs the future M4 auto-update job:

- standalone container  -> recreate-with-pull (existing recreate endpoint)
- stack-managed         -> stack redeploy-with-pull (existing git/file stack
                           update mutations), so the container stays in its
                           stack and is never recreated out-of-band
- externally-managed    -> refused; the details button is disabled with an
  compose                  explanatory tooltip and the bulk action skips it

Decision logic lives in the pure, unit-tested resolveContainerUpdatePath /
groupContainersForUpdate helpers. The bulk action filters to outdated
containers and redeploys each owning stack exactly once even when several of
its containers are selected, reporting per-item success/failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:24:10 +03:00
claude code agent
7eaff4dab0 fix(automation): real status cache read + nodeName key + honest errors (#9 review)
F1: ContainerImageStatus now reads the 24h statusCache (keyed by imageID)
before the remote registry digest lookup, so the cache is effective on the
input side for all callers instead of being write-only. This avoids the
rate-limited registry HEAD on repeat loads.

F2: add nodeName to the imageStatus query key so cached results cannot be
reused across nodes.

F3: correct the swagger annotations to reflect that engine-level issues
degrade to a 200 skipped/error status rather than 400/404.

F4: return a generic error message to the client instead of the raw
registry/engine error; the raw error is still logged server-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:09:18 +03:00
claude code agent
f69eb3f9eb feat(automation): CE container image update detection endpoint + badge (#9, epic #3 M2)
Add native CE detection of "a newer image is available" for running
containers, surfaced as a read-only HTTP endpoint and a containers-list
badge/column. No applying of updates (M3/M4), no auto-heal (M1).

Backend:
- New CE handler GET /docker/{id}/containers/{containerId}/image_status
  backed by the existing zlib/CE digest engine
  (images.NewClientWithRegistry + ContainerImageStatus). Honors nodeName,
  authz, and routes registry calls through the credential store / SSRF
  AllowList. Engine failures degrade to a 200 {Status:"error"} so the UI
  stays graceful. Response shape: {Status, Message?}.

Frontend (CE-only, no isBE gating; the EE ImageStatus component is left
untouched):
- useContainerImageStatus TanStack Query hook (5min staleTime, no
  refetch-on-focus; backend caches 24h) calling the non-proxied endpoint.
- UpdateStatusBadge component (own assets, neutral on skipped/error).
- "Update available" column in the containers datatable; one cached,
  non-blocking query per visible row.

Tests: Go response-shape unit test; vitest for the badge (all statuses)
and the hook (url + nodeName query param via msw).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:59:54 +03:00
claude code agent
b233f75ab7 fix(automation): retry-state retention + running-only heal + per-restart ctx (#8 F1-F6)
F1: prune retry-state by elapsed window since lastRestart instead of "not
seen this tick", so a container flapping through "starting" keeps its
cooldown/max-retries accounting (storm guard no longer defeated). Recovered
containers quiet for > window are still cleaned up.
F2: list running containers only (All:false) so stopped-unhealthy containers
are never revived.
F3: each ContainerRestart gets its own context (stop-timeout + buffer),
separate from the per-endpoint list context, so a slow/hung restart cannot
starve the others or exhaust a single shared deadline.
F4: start() is idempotent (no-op when a job is already scheduled); Reload
still stops first so it always reschedules.
F5: frontend parseBool mirrors Go strconv.ParseBool (case-insensitive
1/t/true; present-but-invalid counts as present & false).
F6: tests TestPruneRetries and TestRetryStateSurvivesStartingTick lock in
the F1 behavior; added AutoHealRow parse cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:39:17 +03:00
claude code agent
51957d2f98 feat(automation): native auto-heal daemon (#8, epic #3 M1)
Add a native, CE-only auto-heal daemon that restarts Docker containers whose
healthcheck reports "unhealthy", replacing the willfarrell/autoheal sidecar.

Backend:
- New package api/containerautomation (service lifecycle + scheduler job,
  per-endpoint heal pass, label/scope parsing, in-memory cooldown/retry state).
- Settings.ContainerAutomation.AutoHeal {Enabled, CheckInterval, Scope} with
  fresh-install defaults and a 2.43.0 migration backfilling existing installs.
- Settings update handler reloads/stops the job via a small Reloader interface
  (no import cycle); service bootstrapped from main.go after stack schedules.

Frontend:
- Global AutoHealPanel in SettingsView (enable / interval / scope) via
  useUpdateSettingsMutation, plus settings TS types.
- Read-only per-container Auto-heal row in the container details view (Docker
  labels are immutable at runtime; opt-in is set via Create/Edit form labels).

Tests: Go unit tests for label/scope resolution and the cooldown/retry decision;
vitest for the panel and the per-container row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:22:46 +03:00
426 changed files with 16776 additions and 903 deletions

View File

@@ -14,6 +14,7 @@ import (
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/chisel"
"github.com/portainer/portainer/api/cli"
"github.com/portainer/portainer/api/containerautomation"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/database"
"github.com/portainer/portainer/api/database/boltdb"
@@ -577,6 +578,10 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
log.Fatal().Err(err).Msg("failed to start stack scheduler")
}
containerService := docker.NewContainerService(dockerClientFactory, dataStore)
containerAutomationService := containerautomation.NewService(shutdownCtx, scheduler, dataStore, dockerClientFactory, containerService, stackDeployer)
containerAutomationService.Start()
sslDBSettings, err := dataStore.SSLSettings().Settings()
if err != nil {
log.Fatal().Msg("failed to fetch SSL settings from DB")
@@ -650,6 +655,7 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
DockerClientFactory: dockerClientFactory,
KubernetesClientFactory: kubernetesClientFactory,
Scheduler: scheduler,
ContainerAutomationService: containerAutomationService,
ShutdownTrigger: shutdownTrigger,
StackDeployer: stackDeployer,
UpgradeService: upgradeService,

View File

@@ -0,0 +1,190 @@
package containerautomation
import (
"context"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/rs/zerolog/log"
)
const (
// retryWindow is the rolling window over which max restarts per container are counted.
retryWindow = 10 * time.Minute
// restartCooldown is the minimum delay between two restarts of the same container,
// giving its healthcheck time to recover before we try again.
restartCooldown = 60 * time.Second
// endpointTimeout bounds the container-list call for a single endpoint.
endpointTimeout = 30 * time.Second
// restartTimeoutBuffer is added on top of a container's stop-timeout to derive
// the deadline of its own restart context, leaving room for the engine to kill
// and start the container after the graceful stop window elapses.
restartTimeoutBuffer = 15 * time.Second
)
// retryState tracks restart accounting for a single container across ticks.
type retryState struct {
attempts int
windowStart time.Time
lastRestart time.Time
}
// retryPolicy holds the cooldown/window parameters applied to a container.
type retryPolicy struct {
maxRetries int
window time.Duration
cooldown time.Duration
}
// decideRestart is a pure function that decides whether an unhealthy container
// should be restarted now, given its current retry state and policy. It returns
// the decision and the updated state to persist.
//
// Rules, in order:
// - reset the window (and attempts) when the window has elapsed;
// - deny while still within the cooldown since the last restart;
// - deny once the max number of restarts in the current window is reached;
// - otherwise restart, incrementing the attempt counter.
func decideRestart(state retryState, policy retryPolicy, now time.Time) (bool, retryState) {
if state.windowStart.IsZero() || now.Sub(state.windowStart) >= policy.window {
state.windowStart = now
state.attempts = 0
}
if !state.lastRestart.IsZero() && now.Sub(state.lastRestart) < policy.cooldown {
return false, state
}
if state.attempts >= policy.maxRetries {
return false, state
}
state.attempts++
state.lastRestart = now
return true, state
}
// heal runs a single auto-heal pass over every reachable Docker endpoint.
// It is registered with the scheduler and guarded against overlapping ticks by
// the Service. Errors are logged per endpoint/container so one failure does not
// abort the whole pass; it always returns nil so the scheduler keeps the job.
func (s *Service) heal() error {
if !s.running.CompareAndSwap(false, true) {
log.Debug().Msg("auto-heal: previous run still in progress, skipping tick")
return nil
}
defer s.running.Store(false)
scope := s.scope()
endpoints, err := s.dataStore.Endpoint().Endpoints()
if err != nil {
log.Warn().Err(err).Msg("auto-heal: unable to list environments")
return nil
}
for i := range endpoints {
endpoint := &endpoints[i]
// M1 scope: native Docker endpoints only. Kubernetes is not applicable and
// Edge/async endpoints are not reachable synchronously from the scheduler.
if !endpointutils.IsDockerEndpoint(endpoint) || endpointutils.IsEdgeEndpoint(endpoint) {
continue
}
// Per-endpoint opt-out (M5): skip environments where automation is disabled,
// independently of the global switch. Zero value participates, so existing
// installs are unaffected.
if !AutomationEnabledForEndpoint(endpoint) {
log.Debug().Int("endpoint_id", int(endpoint.ID)).
Msg("auto-heal: automation disabled for this environment, skipping")
continue
}
s.healEndpoint(endpoint, scope)
}
// Drop retry state only for containers whose retry window has fully elapsed
// since their last restart. A container that briefly leaves the unhealthy
// filter (e.g. while "starting" after a restart) keeps its accounting, so the
// cooldown / max-retries storm guard survives flapping.
s.pruneRetries(time.Now())
return nil
}
// healEndpoint restarts the in-scope unhealthy containers of a single endpoint.
func (s *Service) healEndpoint(endpoint *portainer.Endpoint, scope string) {
endpointID := int(endpoint.ID)
// Swarm note (M1 limitation): we connect to the endpoint's primary node only
// (nodeName ""). Containers scheduled on other Swarm nodes are not healed here;
// per-node iteration is deferred to a later milestone.
clientTimeout := endpointTimeout
cli, err := s.clientFactory.CreateClient(endpoint, "", &clientTimeout)
if err != nil {
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-heal: unable to create Docker client")
return
}
defer cli.Close()
listCtx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
defer cancel()
// List running unhealthy containers only (All:false). Docker keeps
// Health.Status=="unhealthy" on stopped containers, so listing with All:true
// would let us "restart" (i.e. start) an intentionally-stopped container.
listFilters := filters.NewArgs(filters.Arg("health", "unhealthy"))
containers, err := cli.ContainerList(listCtx, container.ListOptions{All: false, Filters: listFilters})
if err != nil {
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-heal: unable to list containers")
return
}
for _, c := range containers {
if !InScope(scope, c.Labels) {
continue
}
policy := retryPolicy{
maxRetries: MaxRetries(c.Labels),
window: retryWindow,
cooldown: restartCooldown,
}
ok, newState := decideRestart(s.getRetry(c.ID), policy, time.Now())
s.setRetry(c.ID, newState)
if !ok {
log.Debug().Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-heal: restart skipped (cooldown or max retries reached)")
continue
}
timeout := StopTimeout(c.Labels)
// Each restart gets its own context, bounded by the container's stop-timeout
// plus a buffer, so one slow restart cannot starve the others and a hung
// engine call is bounded independently of the list deadline.
restartTimeout := time.Duration(timeout)*time.Second + restartTimeoutBuffer
restartCtx, restartCancel := context.WithTimeout(s.baseCtx, restartTimeout)
err := cli.ContainerRestart(restartCtx, c.ID, container.StopOptions{Timeout: &timeout})
restartCancel()
if err != nil {
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-heal: failed to restart unhealthy container")
continue
}
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).Int("attempt", newState.attempts).
Msg("auto-heal: restarted unhealthy container")
s.notifier.Notify(Event{
Kind: EventHealRestarted, EndpointID: endpointID, ContainerID: c.ID,
Message: "restarted unhealthy container",
})
}
}

View File

@@ -0,0 +1,137 @@
package containerautomation
import (
"testing"
"time"
)
func TestDecideRestart(t *testing.T) {
policy := retryPolicy{
maxRetries: 3,
window: 10 * time.Minute,
cooldown: 60 * time.Second,
}
base := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
t.Run("first restart on empty state", func(t *testing.T) {
ok, state := decideRestart(retryState{}, policy, base)
if !ok {
t.Fatal("expected restart on first unhealthy observation")
}
if state.attempts != 1 {
t.Errorf("attempts = %d, want 1", state.attempts)
}
if !state.windowStart.Equal(base) || !state.lastRestart.Equal(base) {
t.Error("windowStart/lastRestart should be set to now")
}
})
t.Run("blocked during cooldown", func(t *testing.T) {
_, state := decideRestart(retryState{}, policy, base)
ok, _ := decideRestart(state, policy, base.Add(30*time.Second))
if ok {
t.Error("expected restart to be blocked within cooldown")
}
})
t.Run("allowed after cooldown", func(t *testing.T) {
_, state := decideRestart(retryState{}, policy, base)
ok, state := decideRestart(state, policy, base.Add(61*time.Second))
if !ok {
t.Error("expected restart allowed after cooldown")
}
if state.attempts != 2 {
t.Errorf("attempts = %d, want 2", state.attempts)
}
})
t.Run("max retries enforced within window", func(t *testing.T) {
state := retryState{}
now := base
allowed := 0
for i := 0; i < 6; i++ {
ok, newState := decideRestart(state, policy, now)
state = newState
if ok {
allowed++
}
now = now.Add(policy.cooldown + time.Second)
}
if allowed != policy.maxRetries {
t.Errorf("allowed %d restarts, want %d (max per window)", allowed, policy.maxRetries)
}
})
t.Run("counter resets after window elapses", func(t *testing.T) {
state := retryState{attempts: 3, windowStart: base, lastRestart: base}
ok, newState := decideRestart(state, policy, base.Add(policy.window+time.Second))
if !ok {
t.Error("expected restart allowed once the window elapsed")
}
if newState.attempts != 1 {
t.Errorf("attempts = %d, want 1 after window reset", newState.attempts)
}
})
}
func TestPruneRetries(t *testing.T) {
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
s := &Service{retries: map[string]retryState{
// within the window -> retained
"fresh": {attempts: 1, windowStart: now.Add(-time.Minute), lastRestart: now.Add(-time.Minute)},
// exactly at the window boundary -> pruned
"edge": {attempts: 2, windowStart: now.Add(-retryWindow), lastRestart: now.Add(-retryWindow)},
// long past the window -> pruned
"stale": {attempts: 3, windowStart: now.Add(-2 * retryWindow), lastRestart: now.Add(-2 * retryWindow)},
}}
s.pruneRetries(now)
if _, ok := s.retries["fresh"]; !ok {
t.Error("entry within the retry window should be retained")
}
if _, ok := s.retries["edge"]; ok {
t.Error("entry exactly at the window boundary should be pruned")
}
if _, ok := s.retries["stale"]; ok {
t.Error("entry past the retry window should be pruned")
}
}
// TestRetryStateSurvivesStartingTick locks in the F1 fix: a container that flaps
// through "starting" right after a restart (and so briefly drops out of the
// health=unhealthy filter) must keep its retry accounting across the tick where
// it is not observed, otherwise the cooldown / max-retries storm guard is
// defeated and the next unhealthy observation triggers an immediate restart.
func TestRetryStateSurvivesStartingTick(t *testing.T) {
policy := retryPolicy{maxRetries: 3, window: retryWindow, cooldown: restartCooldown}
const id = "flapper"
s := &Service{retries: make(map[string]retryState)}
t0 := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
// Tick 1: container is unhealthy -> first restart.
ok, state := decideRestart(s.getRetry(id), policy, t0)
s.setRetry(id, state)
if !ok || state.attempts != 1 {
t.Fatalf("tick 1: ok=%v attempts=%d, want restart with attempts=1", ok, state.attempts)
}
// Tick 2 (t0+30s): the container is "starting" and not in the unhealthy list.
// Prune must NOT drop its state because the window has not elapsed.
s.pruneRetries(t0.Add(30 * time.Second))
if _, kept := s.retries[id]; !kept {
t.Fatal("tick 2: retry state was pruned while the container was 'starting'")
}
// Tick 3 (t0+45s): unhealthy again, still within the cooldown. The surviving
// state must block the restart and the attempt count must not be reset.
ok, state = decideRestart(s.getRetry(id), policy, t0.Add(45*time.Second))
s.setRetry(id, state)
if ok {
t.Error("tick 3: restart should be blocked by the surviving cooldown")
}
if state.attempts != 1 {
t.Errorf("tick 3: attempts = %d, want 1 (state survived, not reset)", state.attempts)
}
}

View File

@@ -0,0 +1,559 @@
package containerautomation
import (
"context"
"fmt"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker/images"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
dockerclient "github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
const (
// statusCheckTimeout bounds a single container image-status resolution
// (container inspect + remote digest fetch).
statusCheckTimeout = 30 * time.Second
// recreateTimeout bounds a standalone recreate (pull + stop + create + start).
// Pulls can be slow, so it is generous.
recreateTimeout = 10 * time.Minute
// stackRedeployTimeout bounds a single stack redeploy-with-pull.
stackRedeployTimeout = 15 * time.Minute
)
// update runs a single auto-update pass over every reachable Docker endpoint.
// It is registered with the scheduler and guarded against overlapping ticks by
// the Service. Errors are logged per endpoint/container so one failure does not
// abort the whole pass; it always returns nil so the scheduler keeps the job.
func (s *Service) update() error {
if !s.updateRunning.CompareAndSwap(false, true) {
log.Debug().Msg("auto-update: previous run still in progress, skipping tick")
return nil
}
defer s.updateRunning.Store(false)
settings, err := s.dataStore.Settings().Settings()
if err != nil {
log.Warn().Err(err).Msg("auto-update: unable to read settings")
return nil
}
scope := ScopeLabeled
if settings.ContainerAutomation.AutoUpdate.Scope == ScopeAll {
scope = ScopeAll
}
opts := updateOptions{
cleanup: settings.ContainerAutomation.AutoUpdate.Cleanup,
rollback: settings.ContainerAutomation.AutoUpdate.RollbackOnFailure,
rollbackTimeout: parseRollbackTimeout(settings.ContainerAutomation.AutoUpdate.RollbackTimeout),
}
endpoints, err := s.dataStore.Endpoint().Endpoints()
if err != nil {
log.Warn().Err(err).Msg("auto-update: unable to list environments")
return nil
}
for i := range endpoints {
endpoint := &endpoints[i]
// Native Docker endpoints only: Kubernetes is not applicable and
// Edge/async endpoints are not reachable synchronously from the scheduler.
if !endpointutils.IsDockerEndpoint(endpoint) || endpointutils.IsEdgeEndpoint(endpoint) {
continue
}
// Per-endpoint opt-out (M5): skip environments where automation is disabled,
// independently of the global switch. Zero value participates, so existing
// installs are unaffected.
if !AutomationEnabledForEndpoint(endpoint) {
log.Debug().Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: automation disabled for this environment, skipping")
continue
}
s.updateEndpoint(endpoint, scope, opts)
}
// Drop rolled-back records whose cooldown has fully elapsed (mirrors auto-heal's
// pruneRetries), so the loop-guard map cannot grow unbounded.
s.pruneRolledBack(time.Now())
return nil
}
// updateOptions carries the per-pass auto-update toggles resolved from settings.
type updateOptions struct {
// cleanup removes the now-dangling old image after a confirmed-good update.
cleanup bool
// rollback enables the health gate + rollback of a failed standalone update.
rollback bool
// rollbackTimeout bounds how long the health gate waits before rolling back.
rollbackTimeout time.Duration
}
// parseRollbackTimeout resolves the configured rollback timeout, falling back to
// the default when empty or unparseable.
func parseRollbackTimeout(raw string) time.Duration {
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
return defaultRollbackTimeout
}
return d
}
// updateEndpoint applies image updates to the in-scope, outdated containers of a
// single endpoint, routing each container to the standalone / stack / external
// apply path. Stack-managed candidates are grouped so each owning stack is
// redeployed at most once per tick.
func (s *Service) updateEndpoint(endpoint *portainer.Endpoint, scope string, opts updateOptions) {
endpointID := int(endpoint.ID)
// Swarm note (M4 limitation, mirrors auto-heal): we connect to the endpoint's
// primary node only (nodeName ""). Containers scheduled on other Swarm nodes
// are not updated here; stacks are redeployed cluster-wide by the swarm engine.
clientTimeout := endpointTimeout
cli, err := s.clientFactory.CreateClient(endpoint, "", &clientTimeout)
if err != nil {
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-update: unable to create Docker client")
return
}
defer cli.Close()
listCtx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
defer cancel()
// Running containers only: a stopped container has nothing to update now and
// would be started by a bare recreate.
containers, err := cli.ContainerList(listCtx, container.ListOptions{All: false})
if err != nil {
log.Warn().Err(err).Int("endpoint_id", endpointID).Msg("auto-update: unable to list containers")
return
}
// Collect the in-scope, outdated, non-monitor-only containers as candidates.
// An in-scope monitor-only container is still status-checked (keeping its badge
// cache warm) but never auto-applied. This only covers in-scope containers: in
// "labeled" scope a monitor-only container without the enable label is filtered
// out below before any status check, so its badge is not refreshed here.
var candidates []UpdateCandidate
for _, c := range containers {
if !InUpdateScope(scope, c.Labels) {
continue
}
// Resolve the image status. This also refreshes the package-level status
// cache that backs the badge, so in-scope monitor-only containers are still
// checked even though they are never auto-applied.
statusCtx, statusCancel := context.WithTimeout(s.baseCtx, statusCheckTimeout)
status, err := s.digestClient.ContainerImageStatus(statusCtx, c.ID, endpoint, "")
statusCancel()
if err != nil {
// Pull / registry-auth / network failure: leave the running container
// untouched, never recreate on a failed check.
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: image status check failed, leaving container untouched")
continue
}
if status != images.Outdated {
continue
}
// Monitor-only: detect-only, never auto-apply (status already cached above).
if IsMonitorOnly(c.Labels) {
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: outdated image detected but container is monitor-only, not applying")
continue
}
candidates = append(candidates, UpdateCandidate{ID: c.ID, Name: containerName(c.Names), ImageID: c.ImageID, Labels: c.Labels})
}
// Route and de-duplicate: one redeploy per stack per tick.
grouped := groupContainersForUpdate(candidates, s.stackLookupForEndpoint(endpoint.ID))
for _, ext := range grouped.External {
log.Debug().Str("container_id", ext.ID).Int("endpoint_id", endpointID).
Msg("auto-update: outdated externally-managed compose container, detect only")
}
for _, c := range grouped.Standalone {
s.updateStandalone(cli, endpoint, c, opts)
}
for _, st := range grouped.Stacks {
s.updateStack(endpoint, st)
}
}
// stackLookupForEndpoint builds a compose-project-name -> Portainer compose stack
// resolver for a single endpoint. Only Docker Compose stacks on this endpoint
// match; a same-named swarm/kubernetes stack is treated as external (mirrors
// M3's resolveContainerUpdatePath).
func (s *Service) stackLookupForEndpoint(endpointID portainer.EndpointID) func(project string) *StackMatch {
stacks, err := s.dataStore.Stack().ReadAll()
if err != nil {
log.Warn().Err(err).Int("endpoint_id", int(endpointID)).
Msg("auto-update: unable to read stacks, treating compose containers as external")
return func(string) *StackMatch { return nil }
}
byName := make(map[string]*StackMatch)
for i := range stacks {
st := &stacks[i]
if st.EndpointID != endpointID || st.Type != portainer.DockerComposeStack {
continue
}
byName[st.Name] = &StackMatch{StackID: int(st.ID), IsGit: st.WorkflowID != 0}
}
return func(project string) *StackMatch {
return byName[project]
}
}
// updateStandalone recreates a standalone container with a re-pull of its image,
// then (when rollback is enabled and the container has a healthcheck) holds a
// health gate over the new container and rolls back to the previous image if it
// fails to become healthy. The old-image cleanup is deliberately ordered AFTER
// the health gate, so the rollback target is never removed before the update is
// confirmed good.
//
// Sequence: capture old image id + original ref + healthcheck -> recreate(pull)
// -> [health gate] -> on healthy: cleanup (if enabled); on unhealthy: rollback
// (never cleanup).
func (s *Service) updateStandalone(cli *dockerclient.Client, endpoint *portainer.Endpoint, c UpdateCandidate, opts updateOptions) {
endpointID := int(endpoint.ID)
// Loop-guard safety: the rolled-back map is keyed by endpoint+name (the only
// identifier that survives a recreate). An unnamed container cannot be recorded
// (recordRolledBack skips it), so with rollback enabled a container that keeps
// failing its health gate would update->rollback every tick with NO suppression.
// Skip the unnamed case when rollback is on so it cannot enter that
// unsuppressable loop; detection/badge refresh already happened upstream and is
// unaffected. (With rollback off there is no rollback to loop, so we proceed.)
if skipUnnamedForRollback(opts.rollback, c.Name) {
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: skipping unnamed standalone container, rollback is enabled but there is no stable name to key the loop guard")
return
}
// Update->rollback loop guard: if this container's update was rolled back
// recently and the remote still points at the SAME failed image, skip it until
// the cooldown elapses. A genuinely new upstream image (a changed remote digest)
// is not blocked.
rollbackMapKey := rollbackKey(endpoint.ID, c.Name)
if rec, ok := s.getRolledBack(rollbackMapKey); ok && s.shouldSkipRolledBack(rollbackMapKey, rec) {
log.Info().Str("container_id", c.ID).Str("container", c.Name).Str("image", rec.ref).Int("endpoint_id", endpointID).
Msg("auto-update: skipping update, a recent rollback failed on this image and the remote is unchanged (cooldown)")
return
}
// Capture the pre-update image identity for a possible rollback. The container
// list gives us the old image id; an inspect adds the original reference (re-tag
// target), whether a usable healthcheck exists, and the healthcheck start_period
// (which must be waited out before deciding). We only health-gate when rollback
// is enabled, the container has a healthcheck, we resolved both the old image id
// and its reference, and that reference is a proper tag (a digest-pinned or bare
// image id cannot be re-tagged, so the gate could never roll back).
oldImageID := c.ImageID
var originalRef string
var startPeriod time.Duration
healthGated := false
if opts.rollback {
// Bound the inspect like every other engine call so a hung/unreachable engine
// cannot block the whole sequential tick until shutdown.
inspectCtx, inspectCancel := context.WithTimeout(s.baseCtx, endpointTimeout)
inspect, err := cli.ContainerInspect(inspectCtx, c.ID)
inspectCancel()
if err != nil {
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: unable to inspect container before update, proceeding without a health gate")
} else {
originalRef = inspect.Config.Image
if oldImageID == "" {
oldImageID = inspect.Image
}
if hc := inspect.Config.Healthcheck; hc != nil {
startPeriod = hc.StartPeriod
}
switch {
case !hasHealthGate(inspect.Config.Healthcheck):
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: container has no healthcheck, updating without a rollback gate")
case oldImageID == "" || originalRef == "":
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: unable to resolve previous image identity, updating without a rollback gate")
case !isTagReference(originalRef):
log.Info().Str("container_id", c.ID).Str("image", originalRef).Int("endpoint_id", endpointID).
Msg("auto-update: health gate skipped, image is digest-pinned and cannot be rolled back")
default:
healthGated = true
}
}
}
ctx, cancel := context.WithTimeout(s.baseCtx, recreateTimeout)
defer cancel()
newContainer, err := s.containerService.Recreate(ctx, endpoint, c.ID, true, "", "")
if err != nil {
// Recreate preserves config and rolls back on a create failure; a pull or
// create failure leaves the original container running.
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: failed to recreate standalone container")
s.notifier.Notify(Event{
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: c.ID,
Message: "failed to recreate standalone container", Err: err,
})
return
}
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: recreated standalone container with updated image")
newImage := ""
if newContainer != nil {
newImage = newContainer.Config.Image
}
// Health gate: roll back if the new container does not become healthy in time.
// The old image is preserved (not cleaned up) until the gate confirms health,
// so the rollback target is still available. The "updated" event is held until
// the gate confirms health, so an observer never sees a misleading
// "updated" -> "rollback" sequence for the same container; on the rollback path
// only EventRollback (or update-failed) is emitted.
if healthGated {
switch s.healthGate(cli, newContainer.ID, opts.rollbackTimeout, startPeriod) {
case gateAborted:
// Server shutdown mid-gate: leave the new container in place, do not roll
// back and do not emit an event (we never observed a real failure).
return
case gateRollback:
s.rollback(cli, endpoint, newContainer.ID, oldImageID, originalRef, c.Name)
return
case gateHealthy:
// Confirmed healthy: fall through to emit "updated" and clean up.
}
}
// Emit "updated" now: either there was no gate (emitted right after recreate,
// as before), or the gate confirmed the new container is healthy.
s.notifier.Notify(Event{
Kind: EventUpdated, EndpointID: endpointID, ContainerID: newContainer.ID,
Image: newImage, Message: "updated standalone container",
})
if opts.cleanup && newContainer != nil && newContainer.Image != oldImageID {
s.cleanupOldImage(cli, endpoint, oldImageID)
}
}
// containerName returns a container's primary name without the leading slash, or
// "" when none is reported. The name is stable across a recreate (Recreate
// assigns a new container ID but preserves the name), so it keys the rolled-back
// loop-guard map.
func containerName(names []string) string {
if len(names) == 0 {
return ""
}
return strings.TrimPrefix(names[0], "/")
}
// skipUnnamedForRollback reports whether a standalone update must be skipped
// because rollback is enabled but the container has no stable name to key the
// loop guard. The rolled-back map is keyed by endpoint+name (the only identifier
// that survives a recreate); without a name the guard cannot record a failed
// target, so a repeatedly-failing update would loop update->rollback every tick
// with no suppression. When rollback is off there is nothing to loop, so an
// unnamed container is still allowed to update.
func skipUnnamedForRollback(rollback bool, name string) bool {
return rollback && name == ""
}
// rollbackKey identifies a standalone container in the rolled-back map by its
// endpoint and (recreate-stable) name. A recreate assigns a new container ID, so
// the ID cannot key state across an update; the name is preserved.
func rollbackKey(endpointID portainer.EndpointID, name string) string {
return fmt.Sprintf("%d/%s", int(endpointID), name)
}
// resolveRemoteDigest fetches the current remote image digest for a reference. It
// tells whether a rolled-back container's upstream target is still the same
// failed image (skip) or a new push (retry).
func (s *Service) resolveRemoteDigest(ctx context.Context, ref string) (string, error) {
img, err := images.ParseImage(images.ParseImageOptions{Name: ref})
if err != nil {
return "", err
}
dig, err := s.digestClient.RemoteDigest(ctx, img)
if err != nil {
return "", err
}
return dig.String(), nil
}
// recordRolledBack stores the failed target after a successful rollback so the
// next poll skips re-pulling the same broken image. The failed remote digest is
// resolved now (the registry is reachable, the image was just pulled); if it
// cannot be resolved the record is still stored with an empty digest and the
// guard skips conservatively until the cooldown elapses.
func (s *Service) recordRolledBack(endpoint *portainer.Endpoint, name, ref string) {
if name == "" {
// Without a stable key we cannot reliably match the container next tick.
log.Debug().Str("image", ref).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: rolled-back container has no name, loop guard not recorded")
return
}
ctx, cancel := context.WithTimeout(s.baseCtx, statusCheckTimeout)
digest, err := s.resolveRemoteDigest(ctx, ref)
cancel()
if err != nil {
log.Debug().Err(err).Str("image", ref).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: could not resolve failed remote digest, loop guard will skip conservatively until cooldown")
}
s.setRolledBack(rollbackKey(endpoint.ID, name), rolledBackTarget{ref: ref, digest: digest, at: time.Now()})
}
// shouldSkipRolledBack reports whether a standalone container must be skipped this
// tick to avoid the update->rollback loop, clearing the record once the skip no
// longer applies (cooldown elapsed or a new upstream image). It resolves the
// current remote digest so a genuinely new image is never blocked.
func (s *Service) shouldSkipRolledBack(key string, rec rolledBackTarget) bool {
now := time.Now()
// Fast paths that avoid a registry call: cooldown elapsed -> clear & proceed;
// no recorded digest -> skip conservatively while the cooldown is open.
if now.Sub(rec.at) >= updateRollbackCooldown {
s.clearRolledBack(key)
return false
}
if rec.digest == "" {
return true
}
ctx, cancel := context.WithTimeout(s.baseCtx, statusCheckTimeout)
currentDigest, err := s.resolveRemoteDigest(ctx, rec.ref)
cancel()
if err != nil {
// Cannot confirm the upstream target changed: stay conservative and skip to
// avoid re-entering the loop, until the cooldown elapses.
log.Debug().Err(err).Str("image", rec.ref).
Msg("auto-update: cannot resolve remote digest for a rolled-back container, skipping until cooldown")
return true
}
if decideUpdateSkip(rec, currentDigest, now, updateRollbackCooldown) {
return true
}
// New upstream image (changed digest): the failed target is gone, clear the
// record and let the update proceed.
s.clearRolledBack(key)
return false
}
// cleanupOldImage attempts a conservative removal of the previous image after a
// standalone update. The removal is NOT forced: Docker refuses to delete an
// image that still carries tags or is referenced by any container, so this only
// succeeds when the old image has become genuinely dangling (untagged and
// unused). It never touches a tagged image still in use.
func (s *Service) cleanupOldImage(cli *dockerclient.Client, endpoint *portainer.Endpoint, oldImageID string) {
if oldImageID == "" {
return
}
ctx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
defer cancel()
if _, err := cli.ImageRemove(ctx, oldImageID, image.RemoveOptions{Force: false, PruneChildren: false}); err != nil {
log.Debug().Err(err).Str("image_id", oldImageID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: old image not removed (still tagged or in use)")
return
}
log.Info().Str("image_id", oldImageID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: removed dangling old image after update")
}
// updateStack applies an image update to a Portainer-managed compose stack so its
// containers are recreated by the stack engine and stay part of the stack. It is
// called at most once per stack per tick.
//
// - git stacks: detect-only here. A git stack's source of truth is its commit;
// this tick's trigger is an image-only update (same compose manifest, newer
// upstream digest), which the git redeploy path (RedeployWhenChanged) would
// short-circuit without applying — while still doing a real git fetch every
// tick. So we skip git stacks: the image update lands on the stack's next git
// change or via a manual "Update now", and we do not fetch git every tick.
// - file stacks: the deployer is driven directly with forcePullImage=true,
// applying the image update immediately.
func (s *Service) updateStack(endpoint *portainer.Endpoint, st StackUpdate) {
if st.IsGit {
// Detect-only: leave git bookkeeping to the git redeploy path. Logged at
// debug so it does not repeat at info on every tick (it would otherwise
// fire for an unchanged git stack indefinitely).
log.Debug().Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: outdated git stack image detected, detect only (applied on next git change or manual update)")
return
}
ctx, cancel := context.WithTimeout(s.baseCtx, stackRedeployTimeout)
defer cancel()
stack, err := s.dataStore.Stack().Read(portainer.StackID(st.StackID))
if err != nil {
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: unable to read stack for redeploy")
return
}
// Resolve registries the same way the established userless/system redeploy does
// (RedeployWhenChanged): scope them to the stack author's access on the endpoint
// and refresh ECR tokens, so an ECR-backed stack authenticates with fresh
// credentials instead of the stale token a raw ReadAll() would pass.
registries, err := deployments.ResolveStackRegistries(s.dataStore, stack, endpoint.ID)
if err != nil {
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: unable to resolve registries for stack redeploy")
return
}
// prune=false (conservative: do not remove resources the user may rely on),
// forcePullImage=true (the whole point), forceRecreate=false.
if stackutils.IsRelativePathStack(stack) {
err = s.stackDeployer.DeployRemoteComposeStack(ctx, stack, endpoint, registries, false, true, false)
} else {
err = s.stackDeployer.DeployComposeStack(ctx, stack, endpoint, registries, false, true, false)
}
if err != nil {
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: failed to redeploy compose stack with re-pull")
return
}
log.Info().Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: redeployed compose stack with updated images")
s.notifier.Notify(Event{
Kind: EventUpdated, EndpointID: int(endpoint.ID), StackID: st.StackID,
Message: "redeployed compose stack with updated images",
})
}

View File

@@ -0,0 +1,241 @@
package containerautomation
import "strconv"
const (
// Scope values shared by the auto-heal and auto-update global settings.
ScopeLabeled = "labeled"
ScopeAll = "all"
// Primary labels (with community aliases) controlling per-container auto-heal.
labelEnable = "io.portainer.autoheal.enable"
labelEnableAlias = "autoheal"
labelStopTimeout = "io.portainer.autoheal.stop-timeout"
labelStopTimeoutAlias = "autoheal.stop.timeout"
labelRetries = "io.portainer.autoheal.retries"
// Primary labels (with watchtower aliases) controlling per-container auto-update.
labelUpdateEnable = "io.portainer.update.enable"
labelUpdateEnableAlias = "com.centurylinklabs.watchtower.enable"
labelUpdateMonitorOnly = "io.portainer.update.monitor-only"
labelUpdateMonitorOnlyAlias = "com.centurylinklabs.watchtower.monitor-only"
// composeProjectLabel identifies the compose project a container belongs to.
composeProjectLabel = "com.docker.compose.project"
// Defaults used when a label is missing or holds an invalid value.
defaultStopTimeout = 10
defaultRetries = 3
)
// InScope reports whether a container is subject to auto-heal given the global
// scope and the container's labels.
//
// - "all": every container is in scope, unless it explicitly opts out with the
// enable label set to false.
// - "labeled" (default): only containers with the enable label set to true.
func InScope(scope string, labels map[string]string) bool {
enabled, present := boolLabel(labels, labelEnable, labelEnableAlias)
switch scope {
case ScopeAll:
if present && !enabled {
return false
}
return true
default: // ScopeLabeled
return present && enabled
}
}
// boolLabel resolves a boolean label (primary key first, alias second).
// It returns the parsed value and whether the label was present at all.
// Invalid values are treated as false but still count as "present".
func boolLabel(labels map[string]string, key, alias string) (value bool, present bool) {
raw, ok := labels[key]
if !ok {
raw, ok = labels[alias]
}
if !ok {
return false, false
}
parsed, err := strconv.ParseBool(raw)
if err != nil {
return false, true
}
return parsed, true
}
// InUpdateScope reports whether a container is subject to auto-update given the
// global scope and the container's labels. It mirrors InScope but reads the
// update enable label (io.portainer.update.enable / watchtower alias):
//
// - "all": every container is in scope, unless it explicitly opts out with the
// update enable label set to false.
// - "labeled" (default): only containers with the update enable label true.
func InUpdateScope(scope string, labels map[string]string) bool {
enabled, present := boolLabel(labels, labelUpdateEnable, labelUpdateEnableAlias)
switch scope {
case ScopeAll:
if present && !enabled {
return false
}
return true
default: // ScopeLabeled
return present && enabled
}
}
// IsMonitorOnly reports whether a container is flagged detect-only via the
// monitor-only label (io.portainer.update.monitor-only / watchtower alias).
// Such containers have their image status resolved (for the badge cache) but are
// never auto-applied.
func IsMonitorOnly(labels map[string]string) bool {
value, present := boolLabel(labels, labelUpdateMonitorOnly, labelUpdateMonitorOnlyAlias)
return present && value
}
// UpdateKind is the apply path resolved for an outdated container.
type UpdateKind string
const (
// UpdateStandalone: recreate-with-pull (no compose project).
UpdateStandalone UpdateKind = "standalone"
// UpdateStack: redeploy the owning Portainer compose stack with re-pull, so
// the container stays part of its stack.
UpdateStack UpdateKind = "stack"
// UpdateExternal: compose-managed but with no matching Portainer compose
// stack record; Portainer must not touch it (would detach it / drift).
UpdateExternal UpdateKind = "external"
)
// StackMatch is the Portainer Docker Compose stack a compose project resolves to.
type StackMatch struct {
StackID int
// IsGit routes file vs git redeploy at apply time.
IsGit bool
}
// UpdateRouting is the decision returned by resolveContainerUpdateRouting.
type UpdateRouting struct {
Kind UpdateKind
StackID int
IsGit bool
}
// resolveContainerUpdateRouting decides how a container's image update must be
// applied, given a lookup that resolves a compose project name to a matching
// Portainer Docker Compose stack (nil when none exists or it is not a compose
// stack). It is the Go equivalent of M3's TS resolveContainerUpdatePath: pure
// and side-effect free so it can be unit-tested without Docker or the datastore.
//
// - No compose project label -> standalone (recreate-with-pull).
// - Compose project matching a Portainer compose stack -> stack
// (redeploy-with-pull, keeps the container in its stack).
// - Compose project with no matching Portainer compose stack -> external
// (managed outside Portainer / a same-named stack of another type), left
// untouched to avoid detaching it or drifting.
func resolveContainerUpdateRouting(labels map[string]string, stackLookup func(project string) *StackMatch) UpdateRouting {
project := labels[composeProjectLabel]
if project == "" {
return UpdateRouting{Kind: UpdateStandalone}
}
match := stackLookup(project)
if match == nil {
return UpdateRouting{Kind: UpdateExternal}
}
return UpdateRouting{Kind: UpdateStack, StackID: match.StackID, IsGit: match.IsGit}
}
// UpdateCandidate is an outdated, in-scope container considered for auto-update.
type UpdateCandidate struct {
ID string
// Name is the container's primary name (no leading slash). It is stable across
// a recreate and keys the update->rollback loop guard.
Name string
ImageID string
Labels map[string]string
}
// StackUpdate identifies a Portainer stack to redeploy once.
type StackUpdate struct {
StackID int
IsGit bool
}
// GroupedUpdates partitions candidates into their apply paths, de-duplicating
// stack containers so each owning stack is redeployed at most once per tick
// (the overlap guard for stack fan-out). Pure and unit-testable, the Go analogue
// of M3's groupContainersForUpdate.
type GroupedUpdates struct {
Standalone []UpdateCandidate
External []UpdateCandidate
Stacks []StackUpdate
}
// groupContainersForUpdate routes each candidate and collapses stack candidates
// so a stack with several outdated containers is redeployed only once.
func groupContainersForUpdate(candidates []UpdateCandidate, stackLookup func(project string) *StackMatch) GroupedUpdates {
grouped := GroupedUpdates{}
seenStacks := make(map[int]bool)
for _, c := range candidates {
routing := resolveContainerUpdateRouting(c.Labels, stackLookup)
switch routing.Kind {
case UpdateStandalone:
grouped.Standalone = append(grouped.Standalone, c)
case UpdateExternal:
grouped.External = append(grouped.External, c)
case UpdateStack:
if seenStacks[routing.StackID] {
continue
}
seenStacks[routing.StackID] = true
grouped.Stacks = append(grouped.Stacks, StackUpdate{StackID: routing.StackID, IsGit: routing.IsGit})
}
}
return grouped
}
// StopTimeout returns the per-container stop timeout (in seconds) from labels,
// falling back to the default when missing or invalid.
func StopTimeout(labels map[string]string) int {
return positiveIntLabel(labels, labelStopTimeout, labelStopTimeoutAlias, defaultStopTimeout)
}
// MaxRetries returns the per-container max restarts per window from labels,
// falling back to the default when missing or invalid.
func MaxRetries(labels map[string]string) int {
return positiveIntLabel(labels, labelRetries, "", defaultRetries)
}
// positiveIntLabel reads an integer label (primary first, optional alias second)
// and returns it when strictly positive, otherwise the provided default.
func positiveIntLabel(labels map[string]string, key, alias string, fallback int) int {
raw, ok := labels[key]
if !ok && alias != "" {
raw, ok = labels[alias]
}
if !ok {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return fallback
}
return value
}

View File

@@ -0,0 +1,231 @@
package containerautomation
import "testing"
func TestInScope(t *testing.T) {
tests := []struct {
name string
scope string
labels map[string]string
want bool
}{
{"labeled: no labels", ScopeLabeled, nil, false},
{"labeled: enable true (primary)", ScopeLabeled, map[string]string{labelEnable: "true"}, true},
{"labeled: enable true (alias)", ScopeLabeled, map[string]string{labelEnableAlias: "true"}, true},
{"labeled: enable false", ScopeLabeled, map[string]string{labelEnable: "false"}, false},
{"labeled: enable bad value", ScopeLabeled, map[string]string{labelEnable: "yepp"}, false},
{"labeled: primary wins over alias", ScopeLabeled, map[string]string{labelEnable: "true", labelEnableAlias: "false"}, true},
{"all: no labels", ScopeAll, nil, true},
{"all: enable true", ScopeAll, map[string]string{labelEnable: "true"}, true},
{"all: explicit opt-out", ScopeAll, map[string]string{labelEnable: "false"}, false},
{"all: opt-out via alias", ScopeAll, map[string]string{labelEnableAlias: "0"}, false},
{"all: bad value is not opt-out", ScopeAll, map[string]string{labelEnable: "nope"}, false},
{"unknown scope falls back to labeled", "weird", map[string]string{labelEnable: "true"}, true},
{"unknown scope, no label", "weird", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := InScope(tt.scope, tt.labels); got != tt.want {
t.Errorf("InScope(%q, %v) = %v, want %v", tt.scope, tt.labels, got, tt.want)
}
})
}
}
func TestStopTimeout(t *testing.T) {
tests := []struct {
name string
labels map[string]string
want int
}{
{"default when missing", nil, defaultStopTimeout},
{"primary value", map[string]string{labelStopTimeout: "25"}, 25},
{"alias value", map[string]string{labelStopTimeoutAlias: "15"}, 15},
{"primary wins over alias", map[string]string{labelStopTimeout: "25", labelStopTimeoutAlias: "15"}, 25},
{"bad value falls back", map[string]string{labelStopTimeout: "abc"}, defaultStopTimeout},
{"zero falls back", map[string]string{labelStopTimeout: "0"}, defaultStopTimeout},
{"negative falls back", map[string]string{labelStopTimeout: "-5"}, defaultStopTimeout},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := StopTimeout(tt.labels); got != tt.want {
t.Errorf("StopTimeout(%v) = %d, want %d", tt.labels, got, tt.want)
}
})
}
}
func TestMaxRetries(t *testing.T) {
tests := []struct {
name string
labels map[string]string
want int
}{
{"default when missing", nil, defaultRetries},
{"explicit value", map[string]string{labelRetries: "5"}, 5},
{"bad value falls back", map[string]string{labelRetries: "lots"}, defaultRetries},
{"zero falls back", map[string]string{labelRetries: "0"}, defaultRetries},
{"no alias for retries", map[string]string{"autoheal.retries": "7"}, defaultRetries},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := MaxRetries(tt.labels); got != tt.want {
t.Errorf("MaxRetries(%v) = %d, want %d", tt.labels, got, tt.want)
}
})
}
}
func TestInUpdateScope(t *testing.T) {
tests := []struct {
name string
scope string
labels map[string]string
want bool
}{
{"labeled: no labels", ScopeLabeled, nil, false},
{"labeled: enable true (primary)", ScopeLabeled, map[string]string{labelUpdateEnable: "true"}, true},
{"labeled: enable true (watchtower alias)", ScopeLabeled, map[string]string{labelUpdateEnableAlias: "true"}, true},
{"labeled: enable false", ScopeLabeled, map[string]string{labelUpdateEnable: "false"}, false},
{"labeled: enable bad value", ScopeLabeled, map[string]string{labelUpdateEnable: "soon"}, false},
{"labeled: primary wins over alias", ScopeLabeled, map[string]string{labelUpdateEnable: "true", labelUpdateEnableAlias: "false"}, true},
{"all: no labels", ScopeAll, nil, true},
{"all: enable true", ScopeAll, map[string]string{labelUpdateEnable: "true"}, true},
{"all: explicit opt-out", ScopeAll, map[string]string{labelUpdateEnable: "false"}, false},
{"all: opt-out via watchtower alias", ScopeAll, map[string]string{labelUpdateEnableAlias: "0"}, false},
{"all: bad value is not opt-out", ScopeAll, map[string]string{labelUpdateEnable: "nope"}, false},
{"unknown scope falls back to labeled", "weird", map[string]string{labelUpdateEnable: "true"}, true},
{"unknown scope, no label", "weird", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := InUpdateScope(tt.scope, tt.labels); got != tt.want {
t.Errorf("InUpdateScope(%q, %v) = %v, want %v", tt.scope, tt.labels, got, tt.want)
}
})
}
}
func TestIsMonitorOnly(t *testing.T) {
tests := []struct {
name string
labels map[string]string
want bool
}{
{"no labels", nil, false},
{"primary true", map[string]string{labelUpdateMonitorOnly: "true"}, true},
{"watchtower alias true", map[string]string{labelUpdateMonitorOnlyAlias: "true"}, true},
{"primary false", map[string]string{labelUpdateMonitorOnly: "false"}, false},
{"bad value", map[string]string{labelUpdateMonitorOnly: "maybe"}, false},
{"primary wins over alias", map[string]string{labelUpdateMonitorOnly: "true", labelUpdateMonitorOnlyAlias: "false"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsMonitorOnly(tt.labels); got != tt.want {
t.Errorf("IsMonitorOnly(%v) = %v, want %v", tt.labels, got, tt.want)
}
})
}
}
func TestResolveContainerUpdateRouting(t *testing.T) {
// stackLookup resolves "my-stack" to compose stack 7 (git) and nothing else,
// mirroring how the job builds a per-endpoint compose-stack index.
stackLookup := func(project string) *StackMatch {
if project == "my-stack" {
return &StackMatch{StackID: 7, IsGit: true}
}
return nil
}
tests := []struct {
name string
labels map[string]string
want UpdateRouting
}{
{
name: "no compose label -> standalone",
labels: map[string]string{"foo": "bar"},
want: UpdateRouting{Kind: UpdateStandalone},
},
{
name: "empty compose label -> standalone",
labels: map[string]string{composeProjectLabel: ""},
want: UpdateRouting{Kind: UpdateStandalone},
},
{
name: "compose project matching a portainer compose stack -> stack",
labels: map[string]string{composeProjectLabel: "my-stack"},
want: UpdateRouting{Kind: UpdateStack, StackID: 7, IsGit: true},
},
{
name: "compose project with no matching stack -> external",
labels: map[string]string{composeProjectLabel: "other"},
want: UpdateRouting{Kind: UpdateExternal},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveContainerUpdateRouting(tt.labels, stackLookup)
if got != tt.want {
t.Errorf("resolveContainerUpdateRouting(%v) = %+v, want %+v", tt.labels, got, tt.want)
}
})
}
}
func TestGroupContainersForUpdate(t *testing.T) {
// stackLookup: "web" -> compose stack 3 (file), "api" -> compose stack 4 (git).
stackLookup := func(project string) *StackMatch {
switch project {
case "web":
return &StackMatch{StackID: 3, IsGit: false}
case "api":
return &StackMatch{StackID: 4, IsGit: true}
default:
return nil
}
}
candidates := []UpdateCandidate{
{ID: "standalone-1"},
{ID: "web-a", Labels: map[string]string{composeProjectLabel: "web"}},
{ID: "web-b", Labels: map[string]string{composeProjectLabel: "web"}}, // same stack -> deduped
{ID: "api-a", Labels: map[string]string{composeProjectLabel: "api"}},
{ID: "ext-1", Labels: map[string]string{composeProjectLabel: "unknown"}},
}
grouped := groupContainersForUpdate(candidates, stackLookup)
if len(grouped.Standalone) != 1 || grouped.Standalone[0].ID != "standalone-1" {
t.Errorf("Standalone = %+v, want one entry standalone-1", grouped.Standalone)
}
if len(grouped.External) != 1 || grouped.External[0].ID != "ext-1" {
t.Errorf("External = %+v, want one entry ext-1", grouped.External)
}
// One redeploy per stack: web appears twice in input but once in output.
if len(grouped.Stacks) != 2 {
t.Fatalf("Stacks = %+v, want 2 deduped stacks", grouped.Stacks)
}
got := map[int]bool{}
for _, st := range grouped.Stacks {
got[st.StackID] = st.IsGit
}
if isGit, ok := got[3]; !ok || isGit {
t.Errorf("stack 3 = (%v, present=%v), want present file stack", isGit, ok)
}
if isGit, ok := got[4]; !ok || !isGit {
t.Errorf("stack 4 = (%v, present=%v), want present git stack", isGit, ok)
}
}

View File

@@ -0,0 +1,75 @@
package containerautomation
import "github.com/rs/zerolog/log"
// EventKind enumerates the container-automation events surfaced to a Notifier.
// The set is intentionally small: it is the seam future milestones extend with
// real senders (Slack/email/webhook) without touching the daemon call sites.
type EventKind string
const (
// EventUpdated is emitted after a container/stack image was updated.
EventUpdated EventKind = "updated"
// EventRollback is emitted after a health-gated rollback to the previous image.
EventRollback EventKind = "rollback"
// EventUpdateFailed is emitted when an update (or its rollback) could not be applied.
EventUpdateFailed EventKind = "update-failed"
// EventHealRestarted is emitted after an unhealthy container was restarted.
EventHealRestarted EventKind = "heal-restarted"
)
// Event is a structured container-automation notification. Optional fields are
// left zero when not applicable to the event (e.g. StackID for a standalone
// update, ContainerID for a stack redeploy).
type Event struct {
Kind EventKind
EndpointID int
ContainerID string
StackID int
Image string
Message string
// Err carries the underlying error for failure events; nil otherwise.
Err error
}
// Notifier receives container-automation events. CE has no generic notification
// subsystem, so the only implementation is logNotifier; this interface is the
// seam external senders plug into later.
type Notifier interface {
Notify(event Event)
}
// logNotifier is the default Notifier: it emits each event as a structured log
// line. It never blocks and never errors, so it is safe to call from the daemon
// hot path.
type logNotifier struct{}
// Notify logs the event with its kind and context fields. Failure events are
// logged at warn (with the error), the rest at info.
func (logNotifier) Notify(event Event) {
entry := log.Info()
if event.Kind == EventUpdateFailed {
entry = log.Warn()
if event.Err != nil {
entry = entry.Err(event.Err)
}
}
entry = entry.Str("event", string(event.Kind)).Int("endpoint_id", event.EndpointID)
if event.ContainerID != "" {
entry = entry.Str("container_id", event.ContainerID)
}
if event.StackID != 0 {
entry = entry.Int("stack_id", event.StackID)
}
if event.Image != "" {
entry = entry.Str("image", event.Image)
}
message := event.Message
if message == "" {
message = "container automation event"
}
entry.Msg("container automation: " + message)
}

View File

@@ -0,0 +1,64 @@
package containerautomation
import (
"errors"
"testing"
portainer "github.com/portainer/portainer/api"
)
// recordingNotifier captures emitted events for assertions in tests.
type recordingNotifier struct {
events []Event
}
func (r *recordingNotifier) Notify(event Event) {
r.events = append(r.events, event)
}
func TestLogNotifierDoesNotPanic(t *testing.T) {
n := logNotifier{}
// Every event kind, including a failure carrying an error, must log without
// panicking and without requiring any optional field.
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerID: "abc", Image: "nginx:latest"})
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, StackID: 7})
n.Notify(Event{Kind: EventRollback, EndpointID: 2, ContainerID: "def", Image: "nginx:1.0"})
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 3, ContainerID: "ghi"})
n.Notify(Event{Kind: EventUpdateFailed, EndpointID: 4, ContainerID: "jkl", Err: errors.New("boom")})
n.Notify(Event{Kind: EventUpdateFailed, EndpointID: 4}) // failure without an error
n.Notify(Event{}) // zero value
}
func TestRecordingNotifierCapturesEvents(t *testing.T) {
r := &recordingNotifier{}
r.Notify(Event{Kind: EventUpdated, EndpointID: 1})
r.Notify(Event{Kind: EventRollback, EndpointID: 1})
if len(r.events) != 2 {
t.Fatalf("captured %d events, want 2", len(r.events))
}
if r.events[0].Kind != EventUpdated || r.events[1].Kind != EventRollback {
t.Errorf("unexpected event kinds: %v, %v", r.events[0].Kind, r.events[1].Kind)
}
}
func TestAutomationEnabledForEndpoint(t *testing.T) {
tests := []struct {
name string
endpoint *portainer.Endpoint
want bool
}{
{name: "nil endpoint is not enabled", endpoint: nil, want: false},
{name: "default (zero value) participates", endpoint: &portainer.Endpoint{}, want: true},
{name: "explicitly disabled opts out", endpoint: &portainer.Endpoint{ContainerAutomationDisabled: true}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := AutomationEnabledForEndpoint(tt.endpoint); got != tt.want {
t.Errorf("AutomationEnabledForEndpoint() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,387 @@
package containerautomation
import (
"context"
"errors"
"regexp"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
"github.com/rs/zerolog/log"
"go.podman.io/image/v5/docker/reference"
)
const (
// defaultRollbackTimeout bounds how long the health gate waits for a freshly
// updated standalone container to become healthy before rolling back.
defaultRollbackTimeout = 120 * time.Second
// rollbackPollInterval is the delay between two health probes of the new
// container while the rollback window is open.
rollbackPollInterval = 3 * time.Second
// rollbackGateBuffer is added to the rollback timeout when deriving the inspect
// context deadline, leaving room for the final probe to complete after the
// decision deadline elapses.
rollbackGateBuffer = 10 * time.Second
// startPeriodBuffer is added to a container's healthcheck start_period when it
// is longer than the rollback timeout, so the gate waits through the whole
// start period (during which Docker reports "starting") plus a small grace
// before deciding. Without it a legitimately slow-starting container would be
// rolled back while it is still initializing normally.
startPeriodBuffer = 15 * time.Second
// maxConsecutiveInspectErrors is how many back-to-back inspect failures the
// health gate tolerates before declaring the update failed. A single transient
// Docker API blip must not trigger a false rollback, so the gate keeps polling
// and only gives up once the failures are clearly not transient.
maxConsecutiveInspectErrors = 3
// updateRollbackCooldown is how long a standalone container whose update was
// rolled back is skipped from updating to the SAME failed image again. It
// breaks the update->rollback loop: without it a persistently-unhealthy new
// image would be re-pulled and rolled back on every poll tick. A genuinely new
// upstream image (a changed remote digest) is not blocked; the cooldown only
// suppresses the exact target that just failed. It is generous because a broken
// upstream image is normally fixed by a new push, which lifts the skip at once.
updateRollbackCooldown = 24 * time.Hour
)
// rolledBackTarget records that a standalone container's update to a specific
// remote image was rolled back, so the same target is skipped until the cooldown
// elapses or the upstream digest changes.
type rolledBackTarget struct {
// ref is the container's original image reference (the re-tag target), used to
// re-resolve the current remote digest on later ticks.
ref string
// digest is the remote image digest that failed the health gate. A later tick
// resolving a DIFFERENT digest (a new upstream push) is allowed through; the
// same digest is skipped until the cooldown elapses. Empty when it could not be
// resolved at rollback time, in which case the guard skips conservatively.
digest string
// at is when the rollback happened; the cooldown is measured from it.
at time.Time
}
// decideUpdateSkip is the pure core of the update->rollback loop guard: given a
// recorded rolled-back target and the freshly-resolved current remote digest, it
// reports whether the standalone update must be skipped this tick. The skip holds
// only while the cooldown is open AND the remote still points at the same failed
// image; once the cooldown elapses the skip is lifted. An unknown recorded digest
// is skipped conservatively (we cannot prove the target changed). Mirrors the
// decideRestart pattern so it is unit-testable without Docker.
func decideUpdateSkip(rec rolledBackTarget, currentDigest string, now time.Time, cooldown time.Duration) bool {
if now.Sub(rec.at) >= cooldown {
return false
}
if rec.digest == "" {
return true
}
return currentDigest == rec.digest
}
// rollbackOutcome is the decision produced from a single health sample.
type rollbackOutcome int
const (
// rollbackContinue: still starting and before the deadline, keep polling.
rollbackContinue rollbackOutcome = iota
// rollbackHealthy: the new container is healthy, accept the update.
rollbackHealthy
// rollbackTrigger: the new container failed the health gate, roll back.
rollbackTrigger
)
// gateResult is the terminal outcome of healthGate. It is a tri-state because a
// shutdown mid-gate must be distinguished from a genuine failure: only a real
// unhealthy/not-running/deadline outcome may roll back.
type gateResult int
const (
// gateHealthy: the new container became healthy in time, accept the update.
gateHealthy gateResult = iota
// gateRollback: the new container failed the gate, roll back to the old image.
gateRollback
// gateAborted: the service base context was cancelled (server shutdown) while
// the gate was open. The new container is left running as-is; no rollback and
// no failure event, since we never observed an actual failure.
gateAborted
)
// imageIDReference matches a content-addressable image id carried verbatim in a
// container's Config.Image when it was started from a bare id (e.g.
// "sha256:ab12…"). Such an id is not a tag and cannot be re-tagged, so it must
// not enable the health gate. A full bare hex id (no algorithm prefix) is
// already rejected by reference.ParseNormalizedNamed; this catches the
// algorithm-prefixed digest form, which otherwise parses as a bogus tag.
var imageIDReference = regexp.MustCompile(`^[a-z0-9]+:[0-9a-f]{64}$`)
// containerHealth is the minimal health signal the gate polls. It is built from
// a container inspect but kept independent of the Docker SDK so the decision
// logic can be unit-tested without a Docker engine.
type containerHealth struct {
// Running reports whether the container is currently running. A container that
// has exited within the window is a failed update.
Running bool
// Status is the Docker health status: "starting", "healthy", "unhealthy" or
// "none"/"" when there is no healthcheck.
Status string
}
// decideRollback is a pure decision over a single health sample taken at time
// `now`, given the rollback `deadline`. It is the testable core of the health
// gate: callers feed it successive samples and act on the outcome.
//
// Rules, in order:
// - healthy -> accept the update (rollbackHealthy);
// - unhealthy -> roll back immediately (Docker only reports unhealthy after the
// configured retries fail, so it is a definitive signal);
// - not running (crashed/exited post-start) -> roll back;
// - still starting past the deadline -> roll back (never became healthy in time);
// - otherwise keep waiting (rollbackContinue).
func decideRollback(h containerHealth, now, deadline time.Time) rollbackOutcome {
switch h.Status {
case string(container.Healthy):
return rollbackHealthy
case string(container.Unhealthy):
return rollbackTrigger
}
if !h.Running {
return rollbackTrigger
}
if !now.Before(deadline) {
return rollbackTrigger
}
return rollbackContinue
}
// effectiveRollbackDeadline derives the health-gate deadline from the gate start
// time, the configured rollback timeout, and the container's healthcheck
// start_period. While a container is within its start_period Docker keeps
// reporting "starting" (it never reports unhealthy yet), so a start_period
// longer than the rollback timeout would otherwise trip a premature rollback
// while the container is initializing normally. The deadline is therefore the
// later of (start + timeout) and (start + start_period + buffer).
func effectiveRollbackDeadline(start time.Time, timeout, startPeriod time.Duration) time.Time {
window := timeout
if startPeriod > 0 {
if d := startPeriod + startPeriodBuffer; d > window {
window = d
}
}
return start.Add(window)
}
// inspectErrorTolerated reports whether the health gate should keep polling after
// `consecutive` back-to-back inspect failures rather than declaring the update
// failed. Up to maxConsecutiveInspectErrors transient errors are tolerated; the
// counter is reset by the caller on any successful inspect.
func inspectErrorTolerated(consecutive int) bool {
return consecutive <= maxConsecutiveInspectErrors
}
// hasHealthGate reports whether a container's healthcheck config yields a usable
// health signal. A nil config, an empty test, or an explicit {"NONE"} disable all
// mean Docker never reports healthy/unhealthy, so there is nothing to gate on.
func hasHealthGate(hc *container.HealthConfig) bool {
if hc == nil || len(hc.Test) == 0 {
return false
}
return hc.Test[0] != "NONE"
}
// isTagReference reports whether ref is a proper tag reference that the health
// gate can roll back. Rolling back re-tags the previous image id onto ref via
// ImageTag, which Docker rejects for a digest-pinned reference (repo@sha256:…)
// with "refusing to create a tag with a digest reference", and which is
// meaningless for a bare image id. Such containers are detected here so the gate
// is skipped instead of silently no-op'ing.
func isTagReference(ref string) bool {
if ref == "" {
return false
}
// Algorithm-prefixed image id (e.g. "sha256:<64 hex>"): a bare id, not a tag.
if imageIDReference.MatchString(ref) {
return false
}
named, err := reference.ParseNormalizedNamed(ref)
if err != nil {
// Unparseable (e.g. a full bare hex image id): not a usable tag target.
return false
}
// A digest-pinned reference (with or without a tag) cannot be re-tagged.
if _, ok := named.(reference.Canonical); ok {
return false
}
return true
}
// healthGate polls the new container's health until it becomes healthy, fails, or
// the rollback window elapses, returning the terminal gateResult.
//
// The polling context is derived from the service base context, so a server
// shutdown ends the wait. A shutdown is reported as gateAborted (leave the new
// container in place, do not roll back): we never observed a real failure, and a
// rollback derived from the cancelled context would itself fail and emit a
// misleading "rollback failed" event on every shutdown during a gate window.
//
// Transient inspect failures (a brief Docker API blip) are tolerated: the gate
// keeps polling and only declares the update failed after more than
// maxConsecutiveInspectErrors consecutive failures, resetting on any success.
//
// Scheduling note (known limitation): this poll runs inside the sequential update
// tick, so N unhealthy standalone containers with rollback enabled can each hold
// the tick for up to their rollback window, delaying other containers/endpoints
// in the same tick. The overlap guard in update() still prevents ticks from
// piling up; this is accepted rather than re-architected (no per-container
// goroutine) to keep the update path simple and ordered.
func (s *Service) healthGate(cli *dockerclient.Client, containerID string, timeout, startPeriod time.Duration) gateResult {
if timeout <= 0 {
timeout = defaultRollbackTimeout
}
deadline := effectiveRollbackDeadline(time.Now(), timeout, startPeriod)
ctx, cancel := context.WithDeadline(s.baseCtx, deadline.Add(rollbackGateBuffer))
defer cancel()
consecutiveErrors := 0
for {
inspect, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
// Server shutdown cancelled the base context: abort without rolling back.
if errors.Is(ctx.Err(), context.Canceled) || errors.Is(s.baseCtx.Err(), context.Canceled) {
log.Debug().Str("container_id", containerID).
Msg("auto-update: health gate aborted due to shutdown")
return gateAborted
}
consecutiveErrors++
if !inspectErrorTolerated(consecutiveErrors) {
// Repeated failures: the container vanished or the engine is
// unreachable, treat as a failed update so the rollback can restore
// the previous image.
log.Warn().Err(err).Str("container_id", containerID).Int("consecutive_errors", consecutiveErrors).
Msg("auto-update: health gate inspect failed repeatedly, treating as unhealthy")
return gateRollback
}
// Tolerate a transient blip: keep polling until the data resolves or the
// deadline passes.
log.Debug().Err(err).Str("container_id", containerID).Int("consecutive_errors", consecutiveErrors).
Msg("auto-update: health gate inspect failed, retrying (transient)")
select {
case <-ctx.Done():
return s.gateDeadlineResult()
case <-time.After(rollbackPollInterval):
}
continue
}
consecutiveErrors = 0
h := containerHealth{Running: inspect.State != nil && inspect.State.Running}
if inspect.State != nil && inspect.State.Health != nil {
h.Status = string(inspect.State.Health.Status)
}
switch decideRollback(h, time.Now(), deadline) {
case rollbackHealthy:
return gateHealthy
case rollbackTrigger:
return gateRollback
}
select {
case <-ctx.Done():
return s.gateDeadlineResult()
case <-time.After(rollbackPollInterval):
}
}
}
// gateDeadlineResult maps a context-done gate exit to its outcome: a base-context
// cancellation (shutdown) aborts without rolling back, while a plain deadline
// (the container never became healthy in time) rolls back.
func (s *Service) gateDeadlineResult() gateResult {
if errors.Is(s.baseCtx.Err(), context.Canceled) {
log.Debug().Msg("auto-update: health gate aborted due to shutdown")
return gateAborted
}
return gateRollback
}
// rollback restores the previous image after a failed health-gated update. It
// re-tags the old image id back onto the container's original reference (which
// the new image currently owns), then recreates the new container on that
// reference with no pull, so Recreate's full config-preservation + create-failure
// rollback is reused while resolving to the old image.
//
// Side effect: re-tagging moves `originalRef` from the new image to the old one,
// leaving the new (unhealthy) image untagged/dangling. It is intentionally left
// in place (not pruned) so an operator can inspect why the update failed.
//
// If any step fails the previous image cannot be safely restored, so the
// (unhealthy) new container is left running rather than destroyed, and a loud
// failure notification is emitted.
func (s *Service) rollback(cli *dockerclient.Client, endpoint *portainer.Endpoint, newContainerID, oldImageID, originalRef, containerName string) {
endpointID := int(endpoint.ID)
log.Warn().Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
Msg("auto-update: new container failed the health gate, rolling back to the previous image")
ctx, cancel := context.WithTimeout(s.baseCtx, recreateTimeout)
defer cancel()
// Re-tag the previous image id back onto the original reference. After the
// update the reference points at the new image; this moves it back so Recreate
// resolves the old image without a pull.
if err := cli.ImageTag(ctx, oldImageID, originalRef); err != nil {
log.Error().Err(err).Str("image_id", oldImageID).Str("image", originalRef).Int("endpoint_id", endpointID).
Msg("auto-update: rollback failed to re-tag the previous image, leaving the unhealthy container in place")
s.notifier.Notify(Event{
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: newContainerID,
Image: originalRef, Message: "rollback failed: could not re-tag previous image", Err: err,
})
return
}
if _, err := s.containerService.Recreate(ctx, endpoint, newContainerID, false, "", ""); err != nil {
log.Error().Err(err).Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
Msg("auto-update: rollback recreate failed, leaving the unhealthy container in place")
s.notifier.Notify(Event{
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: newContainerID,
Image: originalRef, Message: "rollback failed: could not recreate on previous image", Err: err,
})
return
}
log.Warn().Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
Msg("auto-update: rolled back to the previous image after a failed update")
s.notifier.Notify(Event{
Kind: EventRollback, EndpointID: endpointID, ContainerID: newContainerID,
Image: originalRef, Message: "rolled back to previous image after failed health check",
})
// Record the failed target so the next poll does not immediately re-pull the
// same broken image and roll back again (the update->rollback loop). Recorded
// only after a SUCCESSFUL rollback; a changed remote digest later lifts the skip.
s.recordRolledBack(endpoint, containerName, originalRef)
}

View File

@@ -0,0 +1,333 @@
package containerautomation
import (
"testing"
"time"
"github.com/docker/docker/api/types/container"
)
func TestDecideRollback(t *testing.T) {
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
deadline := now.Add(120 * time.Second)
tests := []struct {
name string
health containerHealth
at time.Time
want rollbackOutcome
}{
{
name: "healthy within the window accepts the update",
health: containerHealth{Running: true, Status: string(container.Healthy)},
at: now.Add(10 * time.Second),
want: rollbackHealthy,
},
{
name: "unhealthy triggers an immediate rollback",
health: containerHealth{Running: true, Status: string(container.Unhealthy)},
at: now.Add(10 * time.Second),
want: rollbackTrigger,
},
{
name: "still starting before the deadline keeps polling",
health: containerHealth{Running: true, Status: string(container.Starting)},
at: now.Add(10 * time.Second),
want: rollbackContinue,
},
{
name: "still starting past the deadline rolls back",
health: containerHealth{Running: true, Status: string(container.Starting)},
at: now.Add(121 * time.Second),
want: rollbackTrigger,
},
{
name: "starting exactly at the deadline rolls back",
health: containerHealth{Running: true, Status: string(container.Starting)},
at: deadline,
want: rollbackTrigger,
},
{
name: "exited container rolls back even before the deadline",
health: containerHealth{Running: false, Status: string(container.Starting)},
at: now.Add(5 * time.Second),
want: rollbackTrigger,
},
{
name: "unhealthy wins over a stopped state",
health: containerHealth{Running: false, Status: string(container.Unhealthy)},
at: now.Add(5 * time.Second),
want: rollbackTrigger,
},
{
name: "healthy wins even past the deadline",
health: containerHealth{Running: true, Status: string(container.Healthy)},
at: now.Add(200 * time.Second),
want: rollbackHealthy,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := decideRollback(tt.health, tt.at, deadline); got != tt.want {
t.Errorf("decideRollback() = %v, want %v", got, tt.want)
}
})
}
}
func TestEffectiveRollbackDeadline(t *testing.T) {
start := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
timeout := 120 * time.Second
tests := []struct {
name string
startPeriod time.Duration
want time.Time
}{
{
name: "no start period uses the timeout",
startPeriod: 0,
want: start.Add(timeout),
},
{
name: "start period shorter than timeout uses the timeout",
startPeriod: 30 * time.Second,
want: start.Add(timeout),
},
{
name: "start period longer than timeout extends to start period plus buffer",
startPeriod: 300 * time.Second,
want: start.Add(300*time.Second + startPeriodBuffer),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := effectiveRollbackDeadline(start, timeout, tt.startPeriod); !got.Equal(tt.want) {
t.Errorf("effectiveRollbackDeadline() = %v, want %v", got, tt.want)
}
})
}
}
// TestDecideRollbackWithLongStartPeriod proves the F3 fix end to end at the
// decision layer: with a start_period longer than the configured rollback
// timeout, the start-period-aware deadline keeps a still-starting container
// alive while it is within the start period, and only rolls back after it.
func TestDecideRollbackWithLongStartPeriod(t *testing.T) {
start := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
timeout := 60 * time.Second
startPeriod := 300 * time.Second
deadline := effectiveRollbackDeadline(start, timeout, startPeriod)
starting := containerHealth{Running: true, Status: string(container.Starting)}
// Past the bare timeout but still within the start period: keep waiting.
if got := decideRollback(starting, start.Add(120*time.Second), deadline); got != rollbackContinue {
t.Errorf("within start_period: decideRollback() = %v, want rollbackContinue", got)
}
// After the start period (plus buffer): roll back.
if got := decideRollback(starting, start.Add(330*time.Second), deadline); got != rollbackTrigger {
t.Errorf("after start_period: decideRollback() = %v, want rollbackTrigger", got)
}
}
func TestInspectErrorTolerated(t *testing.T) {
tests := []struct {
name string
consecutive int
want bool
}{
{name: "first transient error is tolerated", consecutive: 1, want: true},
{name: "second consecutive error is tolerated", consecutive: 2, want: true},
{name: "at the threshold is still tolerated", consecutive: maxConsecutiveInspectErrors, want: true},
{name: "beyond the threshold is a failure", consecutive: maxConsecutiveInspectErrors + 1, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := inspectErrorTolerated(tt.consecutive); got != tt.want {
t.Errorf("inspectErrorTolerated(%d) = %v, want %v", tt.consecutive, got, tt.want)
}
})
}
}
func TestIsTagReference(t *testing.T) {
const digest = "sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2"
tests := []struct {
name string
ref string
want bool
}{
{name: "tagged reference is rollbackable", ref: "nginx:1.21", want: true},
{name: "untagged reference (implicit latest) is rollbackable", ref: "nginx", want: true},
{name: "fully-qualified tagged reference is rollbackable", ref: "registry.example.com/team/app:v2", want: true},
{name: "digest-pinned reference cannot be re-tagged", ref: "nginx@" + digest, want: false},
{name: "tagged-and-digest-pinned reference cannot be re-tagged", ref: "nginx:1.21@" + digest, want: false},
{name: "algorithm-prefixed bare image id cannot be re-tagged", ref: digest, want: false},
{name: "full bare hex image id cannot be re-tagged", ref: "02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", want: false},
{name: "empty reference is not rollbackable", ref: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isTagReference(tt.ref); got != tt.want {
t.Errorf("isTagReference(%q) = %v, want %v", tt.ref, got, tt.want)
}
})
}
}
func TestSkipUnnamedForRollback(t *testing.T) {
tests := []struct {
name string
rollback bool
cName string
want bool
}{
{name: "rollback on, unnamed -> skip (unsuppressable loop otherwise)", rollback: true, cName: "", want: true},
{name: "rollback on, named -> proceed (guard can key it)", rollback: true, cName: "web", want: false},
{name: "rollback off, unnamed -> proceed (no rollback to loop)", rollback: false, cName: "", want: false},
{name: "rollback off, named -> proceed", rollback: false, cName: "web", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := skipUnnamedForRollback(tt.rollback, tt.cName); got != tt.want {
t.Errorf("skipUnnamedForRollback(%v, %q) = %v, want %v", tt.rollback, tt.cName, got, tt.want)
}
})
}
}
func TestHasHealthGate(t *testing.T) {
tests := []struct {
name string
hc *container.HealthConfig
want bool
}{
{name: "nil config has no gate", hc: nil, want: false},
{name: "empty test inherits, no usable gate", hc: &container.HealthConfig{Test: nil}, want: false},
{name: "explicit NONE disables the gate", hc: &container.HealthConfig{Test: []string{"NONE"}}, want: false},
{name: "CMD healthcheck yields a gate", hc: &container.HealthConfig{Test: []string{"CMD", "curl", "-f", "localhost"}}, want: true},
{name: "CMD-SHELL healthcheck yields a gate", hc: &container.HealthConfig{Test: []string{"CMD-SHELL", "exit 0"}}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasHealthGate(tt.hc); got != tt.want {
t.Errorf("hasHealthGate() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseRollbackTimeout(t *testing.T) {
tests := []struct {
name string
raw string
want time.Duration
}{
{name: "valid duration", raw: "90s", want: 90 * time.Second},
{name: "empty falls back to default", raw: "", want: defaultRollbackTimeout},
{name: "unparseable falls back to default", raw: "nope", want: defaultRollbackTimeout},
{name: "zero falls back to default", raw: "0s", want: defaultRollbackTimeout},
{name: "negative falls back to default", raw: "-5s", want: defaultRollbackTimeout},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseRollbackTimeout(tt.raw); got != tt.want {
t.Errorf("parseRollbackTimeout(%q) = %v, want %v", tt.raw, got, tt.want)
}
})
}
}
func TestDecideUpdateSkip(t *testing.T) {
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
cooldown := 24 * time.Hour
tests := []struct {
name string
rec rolledBackTarget
currentDigest string
want bool
}{
{
name: "same digest within cooldown is skipped",
rec: rolledBackTarget{digest: "sha256:aaa", at: now.Add(-1 * time.Hour)},
currentDigest: "sha256:aaa",
want: true,
},
{
name: "new digest within cooldown is not skipped",
rec: rolledBackTarget{digest: "sha256:aaa", at: now.Add(-1 * time.Hour)},
currentDigest: "sha256:bbb",
want: false,
},
{
name: "same digest after cooldown is not skipped",
rec: rolledBackTarget{digest: "sha256:aaa", at: now.Add(-25 * time.Hour)},
currentDigest: "sha256:aaa",
want: false,
},
{
name: "unknown recorded digest is skipped conservatively within cooldown",
rec: rolledBackTarget{digest: "", at: now.Add(-1 * time.Hour)},
currentDigest: "sha256:aaa",
want: true,
},
{
name: "unknown recorded digest after cooldown is not skipped",
rec: rolledBackTarget{digest: "", at: now.Add(-25 * time.Hour)},
currentDigest: "sha256:aaa",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := decideUpdateSkip(tt.rec, tt.currentDigest, now, cooldown); got != tt.want {
t.Errorf("decideUpdateSkip() = %v, want %v", got, tt.want)
}
})
}
}
// TestPruneRolledBack locks in the F8 fix: pruneRolledBack must iterate the
// rolledBack map and drop only entries whose cooldown has fully elapsed, keeping
// fresh ones, so the map cannot grow unbounded. It mirrors TestPruneRetries. The
// boundary is inclusive (production uses now.Sub(at) >= updateRollbackCooldown),
// so an entry exactly at the cooldown is pruned.
func TestPruneRolledBack(t *testing.T) {
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
s := &Service{rolledBack: map[string]rolledBackTarget{
// within the cooldown -> retained
"fresh": {ref: "img:fresh", digest: "sha256:aaa", at: now.Add(-updateRollbackCooldown / 2)},
// exactly at the cooldown boundary -> pruned (>= is inclusive)
"edge": {ref: "img:edge", digest: "sha256:bbb", at: now.Add(-updateRollbackCooldown)},
// long past the cooldown -> pruned
"stale": {ref: "img:stale", digest: "sha256:ccc", at: now.Add(-2 * updateRollbackCooldown)},
}}
s.pruneRolledBack(now)
if _, ok := s.rolledBack["fresh"]; !ok {
t.Error("entry within the rollback cooldown should be retained")
}
if _, ok := s.rolledBack["edge"]; ok {
t.Error("entry exactly at the cooldown boundary should be pruned")
}
if _, ok := s.rolledBack["stale"]; ok {
t.Error("entry past the rollback cooldown should be pruned")
}
if len(s.rolledBack) != 1 {
t.Errorf("rolledBack length = %d, want 1", len(s.rolledBack))
}
}

View File

@@ -0,0 +1,314 @@
// Package containerautomation provides native container automation that runs as
// background scheduler jobs. M1 implements auto-heal (restarting Docker
// containers whose healthcheck reports "unhealthy", replacing the
// willfarrell/autoheal sidecar); M4 adds auto-update (periodically detecting
// outdated images and applying updates, replacing the containrrr/watchtower
// sidecar).
package containerautomation
import (
"context"
"sync"
"sync/atomic"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/images"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/rs/zerolog/log"
)
const (
// defaultCheckInterval is used when the configured auto-heal interval is empty or unparseable.
defaultCheckInterval = 30 * time.Second
// defaultPollInterval is used when the configured auto-update interval is empty or unparseable.
// It is conservative (hours) to stay within registry rate limits; the image-status cache is
// short-lived (keyed by the local imageID), so each poll re-checks the remote digest.
defaultPollInterval = 6 * time.Hour
)
// Service manages the lifecycle of the auto-heal and auto-update scheduler jobs
// and keeps the per-container retry state in memory across ticks.
type Service struct {
// baseCtx is the application shutdown context. It is the base for every
// per-operation timeout context, so a server shutdown cancels in-flight heal
// restarts and update redeploys instead of letting them run detached.
baseCtx context.Context
scheduler *scheduler.Scheduler
dataStore dataservices.DataStore
clientFactory *dockerclient.ClientFactory
// Dependencies used by the auto-update job (M4).
digestClient *images.DigestClient
containerService *docker.ContainerService
stackDeployer deployments.StackDeployer
// notifier receives automation events (update/rollback/failure/heal). The
// default is logNotifier; the field is the seam external senders plug into.
notifier Notifier
mu sync.Mutex
healJobID string
updateJobID string
// running guards against overlapping heal ticks.
running atomic.Bool
// updateRunning guards against overlapping update ticks.
updateRunning atomic.Bool
retryMu sync.Mutex
retries map[string]retryState
// rolledBackMu guards rolledBack.
rolledBackMu sync.Mutex
// rolledBack records standalone containers whose update was rolled back, keyed
// by endpoint+name, so the auto-update job does not immediately re-pull the
// same failed image and roll back again on the next tick (the update->rollback
// loop guard, mirroring the auto-heal retries map).
//
// This state is in-memory only and is NOT persisted: after a Portainer restart
// the map is empty, so at most one extra update->rollback cycle per restart is
// possible before the guard re-records the failed target. Persisting it would
// require a datastore schema (key + digest + timestamp) and is intentionally out
// of scope here; the cooldown-bounded single extra cycle is an acceptable
// trade-off against that complexity.
rolledBack map[string]rolledBackTarget
}
// NewService creates a new container automation service. Call Start to schedule
// the jobs according to the persisted settings. baseCtx is the application
// shutdown context: it bounds the job operation contexts so a shutdown cancels
// any in-flight heal/update. The stackDeployer and containerService are used by
// the auto-update job; they may be nil only in tests that do not exercise
// auto-update.
func NewService(
baseCtx context.Context,
scheduler *scheduler.Scheduler,
dataStore dataservices.DataStore,
clientFactory *dockerclient.ClientFactory,
containerService *docker.ContainerService,
stackDeployer deployments.StackDeployer,
) *Service {
if baseCtx == nil {
baseCtx = context.Background()
}
return &Service{
baseCtx: baseCtx,
scheduler: scheduler,
dataStore: dataStore,
clientFactory: clientFactory,
digestClient: images.NewClientWithRegistry(images.NewRegistryClient(dataStore), clientFactory),
containerService: containerService,
stackDeployer: stackDeployer,
notifier: logNotifier{},
retries: make(map[string]retryState),
rolledBack: make(map[string]rolledBackTarget),
}
}
// AutomationEnabledForEndpoint reports whether container automation (auto-heal and
// auto-update) should run for an environment. It is the per-endpoint opt-out (M5)
// layered on top of the global switch: an environment participates unless it has
// been explicitly disabled. The zero value (not disabled) preserves the
// pre-M5 behavior for every existing environment.
func AutomationEnabledForEndpoint(endpoint *portainer.Endpoint) bool {
return endpoint != nil && !endpoint.ContainerAutomationDisabled
}
// Start schedules the enabled jobs according to the persisted settings.
func (s *Service) Start() {
s.mu.Lock()
defer s.mu.Unlock()
s.start()
}
// Reload re-applies the current settings: it stops the running jobs and starts
// fresh ones with the new intervals, or leaves them stopped if disabled. It is
// safe to call after a settings update.
//
// Note: stopping a job unschedules future ticks but does not interrupt a tick
// already in progress. An in-flight heal/update pass runs to completion on its
// original (pre-reload) context and is only cancelled by a server shutdown (via
// baseCtx); the new interval takes effect from the next scheduled tick. The
// overlap guards (running/updateRunning) and the per-map mutexes keep this safe
// against data races, so this is a deliberate behavioural nuance, not a bug.
func (s *Service) Reload() error {
s.mu.Lock()
defer s.mu.Unlock()
s.stop()
s.start()
return nil
}
// start (re)schedules the enabled jobs from settings. Caller must hold s.mu.
func (s *Service) start() {
settings, err := s.dataStore.Settings().Settings()
if err != nil {
log.Warn().Err(err).Msg("container automation: unable to read settings, jobs not scheduled")
return
}
s.startHeal(settings)
s.startUpdate(settings)
}
// startHeal schedules the auto-heal job if enabled. Caller must hold s.mu.
func (s *Service) startHeal(settings *portainer.Settings) {
if s.healJobID != "" {
return
}
autoHeal := settings.ContainerAutomation.AutoHeal
if !autoHeal.Enabled {
return
}
interval, err := time.ParseDuration(autoHeal.CheckInterval)
if err != nil || interval <= 0 {
log.Warn().Str("interval", autoHeal.CheckInterval).Dur("default", defaultCheckInterval).
Msg("auto-heal: invalid check interval, falling back to default")
interval = defaultCheckInterval
}
s.healJobID = s.scheduler.StartJobEvery(interval, s.heal)
log.Info().Dur("interval", interval).Msg("auto-heal: job scheduled")
}
// startUpdate schedules the auto-update job if enabled. Caller must hold s.mu.
func (s *Service) startUpdate(settings *portainer.Settings) {
if s.updateJobID != "" {
return
}
autoUpdate := settings.ContainerAutomation.AutoUpdate
if !autoUpdate.Enabled {
return
}
interval, err := time.ParseDuration(autoUpdate.PollInterval)
if err != nil || interval <= 0 {
log.Warn().Str("interval", autoUpdate.PollInterval).Dur("default", defaultPollInterval).
Msg("auto-update: invalid poll interval, falling back to default")
interval = defaultPollInterval
}
s.updateJobID = s.scheduler.StartJobEvery(interval, s.update)
log.Info().Dur("interval", interval).Msg("auto-update: job scheduled")
}
// stop cancels the running jobs, if any. Caller must hold s.mu.
func (s *Service) stop() {
if s.healJobID != "" {
if err := s.scheduler.StopJob(s.healJobID); err != nil {
log.Warn().Err(err).Msg("auto-heal: could not stop the job")
}
s.healJobID = ""
}
if s.updateJobID != "" {
if err := s.scheduler.StopJob(s.updateJobID); err != nil {
log.Warn().Err(err).Msg("auto-update: could not stop the job")
}
s.updateJobID = ""
}
}
// scope returns the configured auto-heal scope, defaulting to "labeled".
func (s *Service) scope() string {
settings, err := s.dataStore.Settings().Settings()
if err != nil {
return ScopeLabeled
}
if settings.ContainerAutomation.AutoHeal.Scope == ScopeAll {
return ScopeAll
}
return ScopeLabeled
}
// getRetry returns the retry state for a container (zero value if unknown).
func (s *Service) getRetry(containerID string) retryState {
s.retryMu.Lock()
defer s.retryMu.Unlock()
return s.retries[containerID]
}
// setRetry stores the retry state for a container.
func (s *Service) setRetry(containerID string, state retryState) {
s.retryMu.Lock()
defer s.retryMu.Unlock()
s.retries[containerID] = state
}
// getRolledBack returns the rolled-back target for a key and whether it exists.
func (s *Service) getRolledBack(key string) (rolledBackTarget, bool) {
s.rolledBackMu.Lock()
defer s.rolledBackMu.Unlock()
rec, ok := s.rolledBack[key]
return rec, ok
}
// setRolledBack records a rolled-back target for a key.
func (s *Service) setRolledBack(key string, rec rolledBackTarget) {
s.rolledBackMu.Lock()
defer s.rolledBackMu.Unlock()
s.rolledBack[key] = rec
}
// clearRolledBack drops the rolled-back record for a key (cooldown elapsed or a
// new upstream image lifted the skip).
func (s *Service) clearRolledBack(key string) {
s.rolledBackMu.Lock()
defer s.rolledBackMu.Unlock()
delete(s.rolledBack, key)
}
// pruneRolledBack drops rolled-back records whose cooldown has fully elapsed, so
// the map cannot grow unbounded. It mirrors pruneRetries.
func (s *Service) pruneRolledBack(now time.Time) {
s.rolledBackMu.Lock()
defer s.rolledBackMu.Unlock()
for key, rec := range s.rolledBack {
if now.Sub(rec.at) >= updateRollbackCooldown {
delete(s.rolledBack, key)
}
}
}
// pruneRetries drops retry state for containers whose retry window has fully
// elapsed since their last restart. A container is kept regardless of whether it
// appeared in the current tick: one that briefly leaves the unhealthy filter
// (e.g. while "starting" right after a restart) must not lose its accounting, or
// the cooldown / max-retries storm guard would be defeated. A container that has
// recovered and stayed quiet for longer than the window is cleaned up (fresh
// budget next incident, no unbounded growth).
func (s *Service) pruneRetries(now time.Time) {
s.retryMu.Lock()
defer s.retryMu.Unlock()
for id, state := range s.retries {
if now.Sub(state.lastRestart) >= retryWindow {
delete(s.retries, id)
}
}
}

View File

@@ -62,6 +62,17 @@ func (store *Store) checkOrCreateDefaultSettings() error {
EnforceEdgeID: true,
}
defaultSettings.ContainerAutomation.AutoHeal.Enabled = false
defaultSettings.ContainerAutomation.AutoHeal.CheckInterval = "30s"
defaultSettings.ContainerAutomation.AutoHeal.Scope = "labeled"
defaultSettings.ContainerAutomation.AutoUpdate.Enabled = false
defaultSettings.ContainerAutomation.AutoUpdate.PollInterval = "6h"
defaultSettings.ContainerAutomation.AutoUpdate.Scope = "labeled"
defaultSettings.ContainerAutomation.AutoUpdate.Cleanup = false
defaultSettings.ContainerAutomation.AutoUpdate.RollbackOnFailure = false
defaultSettings.ContainerAutomation.AutoUpdate.RollbackTimeout = "120s"
return store.SettingsService.UpdateSettings(defaultSettings)
}
if err != nil {

View File

@@ -248,3 +248,41 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
return nil
}
// migrateContainerAutomationSettings_2_43_0 backfills the native container
// automation defaults into existing installs so the new ContainerAutomation
// block is populated without changing behavior: auto-heal (disabled, 30s
// interval, "labeled" scope) and auto-update (disabled, 6h interval, "labeled"
// scope, no cleanup, no rollback with a 120s rollback timeout).
func (m *Migrator) migrateContainerAutomationSettings_2_43_0() error {
log.Info().Msg("backfilling container automation (auto-heal, auto-update) settings")
settings, err := m.settingsService.Settings()
if err != nil {
return err
}
autoHeal := &settings.ContainerAutomation.AutoHeal
if autoHeal.CheckInterval == "" {
autoHeal.CheckInterval = "30s"
}
if autoHeal.Scope == "" {
autoHeal.Scope = "labeled"
}
autoUpdate := &settings.ContainerAutomation.AutoUpdate
if autoUpdate.PollInterval == "" {
autoUpdate.PollInterval = "6h"
}
if autoUpdate.Scope == "" {
autoUpdate.Scope = "labeled"
}
if autoUpdate.RollbackTimeout == "" {
autoUpdate.RollbackTimeout = "120s"
}
return m.settingsService.UpdateSettings(settings)
}

View File

@@ -275,6 +275,7 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.43.0",
m.migrateGitConfigToSources_2_43_0,
m.migrateCustomTemplateGitConfigToSources_2_43_0,
m.migrateContainerAutomationSettings_2_43_0,
)
// WARNING: do not change migrations that have already been released!

View File

@@ -585,6 +585,21 @@
"AllowStackManagementForRegularUsers": true,
"AuthenticationMethod": 1,
"BlackListedLabels": [],
"ContainerAutomation": {
"AutoHeal": {
"CheckInterval": "30s",
"Enabled": false,
"Scope": "labeled"
},
"AutoUpdate": {
"Cleanup": false,
"Enabled": false,
"PollInterval": "6h",
"RollbackOnFailure": false,
"RollbackTimeout": "120s",
"Scope": "labeled"
}
},
"Edge": {
"CommandInterval": 0,
"PingInterval": 0,
@@ -921,7 +936,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.43.0\",\"MigratorCount\":2,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.43.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null,
"workflows": null

View File

@@ -30,12 +30,22 @@ const (
)
const (
// statusCacheTTL bounds how long a computed image status is served from the
// statusCache. It is intentionally short (tied to the auto-update poll window),
// NOT the previous 24h: the cache key is the LOCAL imageID, which does not
// change when upstream pushes a new image under the same tag. A long TTL would
// therefore keep serving a stale "updated" status for up to a day, and the
// auto-update daemon (which resolves status through this same path) could not
// see a freshly-pushed image within its poll interval. A few minutes still
// absorbs bursts of badge lookups for the same image while re-checking the
// remote digest soon after an upstream push.
statusCacheTTL = 5 * time.Minute
errorStatusCacheTTL = 5 * time.Minute
maxConcurrentStatusChecks = 8
)
var (
statusCache = cache.New(24*time.Hour, 24*time.Hour)
statusCache = cache.New(statusCacheTTL, statusCacheTTL)
remoteDigestCache = cache.New(5*time.Second, 5*time.Second)
swarmID2NameCache = cache.New(5*time.Second, 5*time.Second)
)
@@ -134,6 +144,20 @@ func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID str
return Skipped, nil
}
// statusCache is keyed by the LOCAL imageID and read here so every caller
// (handler, ContainersImageStatus, the auto-update job) can skip the expensive,
// rate-limited remote registry digest lookup below on a hit; the container/image
// inspects above are cheap local Docker calls, the registry HEAD is the part
// worth avoiding. The entry TTL is deliberately short (statusCacheTTL): because
// the key is the local imageID, a new upstream image pushed under the same tag
// leaves the key unchanged, so a long TTL would keep serving a stale "updated"
// status (the full computation would now return "outdated") until it expired. A
// short TTL re-checks the remote digest within the poll window. Both Outdated
// and Skipped are cached too (only the error paths return early without caching).
if s, err := CachedResourceImageStatus(imageID); err == nil {
return s, nil
}
digs := make([]digest.Digest, 0)
images := make([]*Image, 0)
if i, err := ParseImage(ParseImageOptions{Name: container.Config.Image}); err == nil {

View File

@@ -2,10 +2,31 @@ package images
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestStatusCacheTTLIsShort is a regression guard for the stale-detection bug: the
// statusCache is keyed by the LOCAL imageID, which does not change when upstream
// pushes a new image under the same tag. A long TTL (the previous 24h) would serve
// a stale "updated" status and hide a freshly-pushed image from both the badge and
// the auto-update daemon for up to a day. The TTL must stay tied to the poll window
// (a few minutes), and entries set with the default expiration (0) must actually
// expire rather than live forever.
func TestStatusCacheTTLIsShort(t *testing.T) {
require.LessOrEqual(t, statusCacheTTL, 10*time.Minute, "status cache TTL must be short, not 24h")
key := "status-test-ttl-key"
CacheResourceImageStatus(key, Updated)
defer EvictImageStatus(key)
_, exp, ok := statusCache.GetWithExpiration(key)
require.True(t, ok)
require.False(t, exp.IsZero(), "status entries must expire, not live forever")
require.LessOrEqual(t, time.Until(exp), statusCacheTTL)
}
func TestAggregateImageStatus(t *testing.T) {
t.Parallel()

View File

@@ -34,6 +34,7 @@ func NewHandler(routePrefix string, bouncer security.BouncerService, dataStore d
router.Use(bouncer.AuthenticatedAccess, middlewares.CheckEndpointAuthorization(bouncer))
router.Handle("/{containerId}/gpus", httperror.LoggerHandler(h.containerGpusInspect)).Methods(http.MethodGet)
router.Handle("/{containerId}/image_status", httperror.LoggerHandler(h.imageStatus)).Methods(http.MethodGet)
router.Handle("/{containerId}/recreate", httperror.LoggerHandler(h.recreate)).Methods(http.MethodPost)
return h

View File

@@ -0,0 +1,84 @@
package containers
import (
"net/http"
"github.com/portainer/portainer/api/docker/images"
"github.com/portainer/portainer/api/http/middlewares"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
// imageStatusResponse is the body returned by the image status endpoint.
type imageStatusResponse struct {
// Status of the running container image. One of:
// "outdated", "updated", "skipped", "processing", "preparing", "error".
Status string `json:"Status"`
// Message holds an optional human-readable detail, typically the detection error.
Message string `json:"Message,omitempty"`
}
// @id ContainerImageStatus
// @summary Fetch the image status of a container
// @description Detect whether a newer image is available for the running container by
// @description comparing the local image digest against the remote registry digest.
// @description This is a read-only operation: it never pulls or recreates anything.
// @description **Access policy**: authenticated
// @tags docker
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param id path int true "Environment identifier"
// @param containerId path string true "Container identifier"
// @param nodeName query string false "Node name for a Swarm/agent endpoint"
// @description Engine-level issues (container not found, registry unreachable, auth
// @description failure, ...) are not treated as API errors: they degrade gracefully to a
// @description 200 response carrying a "skipped" or "error" status. HTTP errors are only
// @description returned for request/authorization problems.
// @success 200 {object} imageStatusResponse "Image status (also returned with a skipped/error status for engine-level issues)"
// @failure 400 "Invalid request: missing container identifier"
// @failure 403 "Permission denied to access the environment"
// @failure 404 "Environment not found"
// @router /docker/{id}/containers/{containerId}/image_status [get]
func (handler *Handler) imageStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
containerID, err := request.RetrieveRouteVariableValue(r, "containerId")
if err != nil {
return httperror.BadRequest("Invalid containerId", err)
}
// nodeName is optional and only relevant for Swarm/agent endpoints.
nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true)
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
return httperror.NotFound("Unable to find an environment on request context", err)
}
if err := handler.bouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
// The detection engine (zlib/CE) routes outbound registry calls through the
// RegistryClient, which honors the encrypted credential store. It caches results
// briefly and skips digest-pinned/local-only images. Note: the outbound registry
// HEAD (RemoteDigest -> docker.GetDigest) is NOT run through an SSRF/AllowList
// filter; this mirrors upstream ContainersImageStatus behaviour.
digestClient := images.NewClientWithRegistry(images.NewRegistryClient(handler.dataStore), handler.dockerClientFactory)
status, err := digestClient.ContainerImageStatus(r.Context(), containerID, endpoint, nodeName)
if err != nil {
// A detection failure (registry unreachable, auth failure, ...) is not an API
// failure: degrade gracefully with a 200 + "error" status so the UI can render a
// neutral badge instead of surfacing a hard error. The raw error is logged
// server-side only; the response carries a generic message to avoid leaking
// registry URLs or credential details to the client.
log.Warn().Err(err).Str("containerId", containerID).Msg("unable to determine container image status")
return response.JSON(w, &imageStatusResponse{Status: string(images.Error), Message: "unable to determine image status"})
}
return response.JSON(w, &imageStatusResponse{Status: string(status)})
}

View File

@@ -0,0 +1,53 @@
package containers
import (
"encoding/json"
"testing"
"github.com/portainer/portainer/api/docker/images"
)
// TestImageStatusResponse_JSONShape verifies the wire format of the image status
// response: the status string is mapped from the engine enum and the message is
// omitted when empty, matching what the CE frontend badge expects.
func TestImageStatusResponse_JSONShape(t *testing.T) {
tests := []struct {
name string
resp imageStatusResponse
expected string
}{
{
name: "outdated without message",
resp: imageStatusResponse{Status: string(images.Outdated)},
expected: `{"Status":"outdated"}`,
},
{
name: "updated without message",
resp: imageStatusResponse{Status: string(images.Updated)},
expected: `{"Status":"updated"}`,
},
{
name: "skipped without message",
resp: imageStatusResponse{Status: string(images.Skipped)},
expected: `{"Status":"skipped"}`,
},
{
name: "error carries a message",
resp: imageStatusResponse{Status: string(images.Error), Message: "registry unreachable"},
expected: `{"Status":"error","Message":"registry unreachable"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := json.Marshal(tt.resp)
if err != nil {
t.Fatalf("unexpected marshal error: %v", err)
}
if string(got) != tt.expected {
t.Errorf("unexpected JSON\n got: %s\nwant: %s", got, tt.expected)
}
})
}
}

View File

@@ -53,6 +53,9 @@ type endpointUpdatePayload struct {
EdgeCheckinInterval *int `example:"5"`
// Associated Kubernetes data
Kubernetes *portainer.KubernetesData
// ContainerAutomationDisabled opts this environment out of native container
// automation (auto-heal / auto-update) regardless of the global switch.
ContainerAutomationDisabled *bool `example:"false"`
}
func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
@@ -120,6 +123,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
endpoint.Gpus = payload.Gpus
}
if payload.ContainerAutomationDisabled != nil {
endpoint.ContainerAutomationDisabled = *payload.ContainerAutomationDisabled
}
endpoint.PublicURL = *cmp.Or(payload.PublicURL, &endpoint.PublicURL)
endpoint.EdgeCheckinInterval = *cmp.Or(payload.EdgeCheckinInterval, &endpoint.EdgeCheckinInterval)

View File

@@ -17,15 +17,23 @@ func hideFields(settings *portainer.Settings) {
settings.OAuthSettings.KubeSecretKey = nil
}
// ContainerAutomationReloader re-applies container automation settings (e.g. the
// auto-heal scheduler job) after a settings change. It is a minimal interface so
// the settings handler does not depend on the concrete service implementation.
type ContainerAutomationReloader interface {
Reload() error
}
// Handler is the HTTP handler used to handle settings operations.
type Handler struct {
*mux.Router
DataStore dataservices.DataStore
FileService portainer.FileService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
SnapshotService portainer.SnapshotService
SetupTokenRequired bool
DataStore dataservices.DataStore
FileService portainer.FileService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
SnapshotService portainer.SnapshotService
SetupTokenRequired bool
ContainerAutomationService ContainerAutomationReloader
}
// NewHandler creates a handler to manage settings operations.

View File

@@ -18,9 +18,28 @@ import (
"github.com/portainer/portainer/pkg/validate"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
)
// minAutoHealCheckInterval is the lower bound for the auto-heal check interval.
// A near-zero interval (e.g. 1ms) is valid as a positive duration but would hammer
// Docker with list/inspect calls for no benefit; keep a sane floor, mirroring the
// auto-update poll-interval validation.
const minAutoHealCheckInterval = time.Second
// minAutoUpdatePollInterval is the lower bound for the auto-update poll interval.
// Polling more often than this hammers registries (rate limits) for no benefit:
// the image-status cache (~5m) bounds detection latency, so a sub-minute interval
// only adds registry load without resolving new images any faster.
const minAutoUpdatePollInterval = time.Minute
// minAutoUpdateRollbackTimeout is the lower bound for the health-gate rollback
// timeout. A near-zero timeout (e.g. 1ms) would roll back almost immediately,
// before any container can pass its healthcheck, defeating the gate; keep a sane
// floor so the gate has a realistic chance to observe health.
const minAutoUpdateRollbackTimeout = 10 * time.Second
type settingsUpdatePayload struct {
// URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string
LogoURL *string `example:"https://mycompany.mydomain.tld/logo.png"`
@@ -56,6 +75,28 @@ type settingsUpdatePayload struct {
EdgePortainerURL *string `json:"EdgePortainerURL"`
// ForceSecureCookies forces the Secure attribute on auth cookies regardless of the detected scheme
ForceSecureCookies *bool `example:"false"`
// Native container automation settings (auto-heal / auto-update)
ContainerAutomation *containerAutomationSettingsPayload
}
type containerAutomationSettingsPayload struct {
AutoHeal *autoHealSettingsPayload
AutoUpdate *autoUpdateSettingsPayload
}
type autoHealSettingsPayload struct {
Enabled *bool `example:"false"`
CheckInterval *string `example:"30s"`
Scope *string `example:"labeled"`
}
type autoUpdateSettingsPayload struct {
Enabled *bool `example:"false"`
PollInterval *string `example:"6h"`
Scope *string `example:"labeled"`
Cleanup *bool `example:"false"`
RollbackOnFailure *bool `example:"false"`
RollbackTimeout *string `example:"120s"`
}
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
@@ -105,6 +146,38 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
}
}
if payload.ContainerAutomation != nil && payload.ContainerAutomation.AutoHeal != nil {
autoHeal := payload.ContainerAutomation.AutoHeal
if autoHeal.CheckInterval != nil {
if d, err := time.ParseDuration(*autoHeal.CheckInterval); err != nil || d < minAutoHealCheckInterval {
return errors.New("Invalid auto-heal check interval. Must be a duration of at least 1s (e.g. 30s)")
}
}
if autoHeal.Scope != nil && *autoHeal.Scope != "labeled" && *autoHeal.Scope != "all" {
return errors.New("Invalid auto-heal scope. Value must be one of: labeled, all")
}
}
if payload.ContainerAutomation != nil && payload.ContainerAutomation.AutoUpdate != nil {
autoUpdate := payload.ContainerAutomation.AutoUpdate
if autoUpdate.PollInterval != nil {
if d, err := time.ParseDuration(*autoUpdate.PollInterval); err != nil || d < minAutoUpdatePollInterval {
return errors.New("Invalid auto-update poll interval. Must be a duration of at least 1m (e.g. 6h)")
}
}
if autoUpdate.Scope != nil && *autoUpdate.Scope != "labeled" && *autoUpdate.Scope != "all" {
return errors.New("Invalid auto-update scope. Value must be one of: labeled, all")
}
if autoUpdate.RollbackTimeout != nil {
if d, err := time.ParseDuration(*autoUpdate.RollbackTimeout); err != nil || d < minAutoUpdateRollbackTimeout {
return errors.New("Invalid auto-update rollback timeout. Must be a duration of at least 10s (e.g. 120s)")
}
}
}
return nil
}
@@ -138,6 +211,15 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
return response.TxErrorResponse(err)
}
// Re-apply container automation settings so the auto-heal and auto-update jobs
// are rescheduled (or stopped) with the new interval/scope after a successful
// save.
if handler.ContainerAutomationService != nil {
if err := handler.ContainerAutomationService.Reload(); err != nil {
log.Warn().Err(err).Msg("unable to reload container automation settings")
}
}
hideFields(settings)
return response.JSON(w, settings)
}
@@ -236,6 +318,25 @@ func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload sett
settings.KubectlShellImage = *cmp.Or(payload.KubectlShellImage, &settings.KubectlShellImage)
if payload.ContainerAutomation != nil && payload.ContainerAutomation.AutoHeal != nil {
autoHeal := payload.ContainerAutomation.AutoHeal
current := &settings.ContainerAutomation.AutoHeal
current.Enabled = *cmp.Or(autoHeal.Enabled, &current.Enabled)
current.CheckInterval = *cmp.Or(autoHeal.CheckInterval, &current.CheckInterval)
current.Scope = *cmp.Or(autoHeal.Scope, &current.Scope)
}
if payload.ContainerAutomation != nil && payload.ContainerAutomation.AutoUpdate != nil {
autoUpdate := payload.ContainerAutomation.AutoUpdate
current := &settings.ContainerAutomation.AutoUpdate
current.Enabled = *cmp.Or(autoUpdate.Enabled, &current.Enabled)
current.PollInterval = *cmp.Or(autoUpdate.PollInterval, &current.PollInterval)
current.Scope = *cmp.Or(autoUpdate.Scope, &current.Scope)
current.Cleanup = *cmp.Or(autoUpdate.Cleanup, &current.Cleanup)
current.RollbackOnFailure = *cmp.Or(autoUpdate.RollbackOnFailure, &current.RollbackOnFailure)
current.RollbackTimeout = *cmp.Or(autoUpdate.RollbackTimeout, &current.RollbackTimeout)
}
if err := tx.Settings().UpdateSettings(settings); err != nil {
return nil, httperror.InternalServerError("Unable to persist settings changes inside the database", err)
}

View File

@@ -0,0 +1,128 @@
package settings
import (
"net/http/httptest"
"testing"
)
func strptr(s string) *string { return &s }
// TestSettingsUpdatePayloadValidateAutoUpdatePollInterval covers the auto-update
// poll-interval floor (F3): durations below minAutoUpdatePollInterval (1m), as
// well as malformed or non-positive durations, must be rejected.
func TestSettingsUpdatePayloadValidateAutoUpdatePollInterval(t *testing.T) {
cases := []struct {
name string
interval string
wantErr bool
}{
{name: "one second is below the floor", interval: "1s", wantErr: true},
{name: "fifty-nine seconds is below the floor", interval: "59s", wantErr: true},
{name: "exactly one minute is allowed", interval: "1m", wantErr: false},
{name: "six hours is allowed", interval: "6h", wantErr: false},
{name: "zero is rejected", interval: "0s", wantErr: true},
{name: "negative is rejected", interval: "-5m", wantErr: true},
{name: "unparseable is rejected", interval: "soon", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload := settingsUpdatePayload{
ContainerAutomation: &containerAutomationSettingsPayload{
AutoUpdate: &autoUpdateSettingsPayload{
PollInterval: strptr(tc.interval),
},
},
}
err := payload.Validate(httptest.NewRequest("PUT", "/settings", nil))
if tc.wantErr && err == nil {
t.Errorf("Validate(%q) = nil, want error", tc.interval)
}
if !tc.wantErr && err != nil {
t.Errorf("Validate(%q) = %v, want nil", tc.interval, err)
}
})
}
}
// TestSettingsUpdatePayloadValidateAutoHealCheckInterval covers the auto-heal
// check-interval floor (F6): durations below minAutoHealCheckInterval (1s), as
// well as malformed or non-positive durations, must be rejected, mirroring the
// auto-update poll-interval validation.
func TestSettingsUpdatePayloadValidateAutoHealCheckInterval(t *testing.T) {
cases := []struct {
name string
interval string
wantErr bool
}{
{name: "one millisecond is below the floor", interval: "1ms", wantErr: true},
{name: "half a second is below the floor", interval: "500ms", wantErr: true},
{name: "exactly one second is allowed", interval: "1s", wantErr: false},
{name: "thirty seconds is allowed", interval: "30s", wantErr: false},
{name: "zero is rejected", interval: "0s", wantErr: true},
{name: "negative is rejected", interval: "-5s", wantErr: true},
{name: "unparseable is rejected", interval: "soon", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload := settingsUpdatePayload{
ContainerAutomation: &containerAutomationSettingsPayload{
AutoHeal: &autoHealSettingsPayload{
CheckInterval: strptr(tc.interval),
},
},
}
err := payload.Validate(httptest.NewRequest("PUT", "/settings", nil))
if tc.wantErr && err == nil {
t.Errorf("Validate(%q) = nil, want error", tc.interval)
}
if !tc.wantErr && err != nil {
t.Errorf("Validate(%q) = %v, want nil", tc.interval, err)
}
})
}
}
// TestSettingsUpdatePayloadValidateRollbackTimeout covers the M5 health-gated
// rollback timeout and its floor (F7): it must be a Go duration of at least
// minAutoUpdateRollbackTimeout (10s), rejecting near-zero, non-positive and
// malformed values.
func TestSettingsUpdatePayloadValidateRollbackTimeout(t *testing.T) {
cases := []struct {
name string
timeout string
wantErr bool
}{
{name: "two minutes is allowed", timeout: "120s", wantErr: false},
{name: "compound duration is allowed", timeout: "1m30s", wantErr: false},
{name: "exactly the floor is allowed", timeout: "10s", wantErr: false},
{name: "one millisecond is below the floor", timeout: "1ms", wantErr: true},
{name: "nine seconds is below the floor", timeout: "9s", wantErr: true},
{name: "zero is rejected", timeout: "0s", wantErr: true},
{name: "negative is rejected", timeout: "-5s", wantErr: true},
{name: "unparseable is rejected", timeout: "soon", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload := settingsUpdatePayload{
ContainerAutomation: &containerAutomationSettingsPayload{
AutoUpdate: &autoUpdateSettingsPayload{
RollbackTimeout: strptr(tc.timeout),
},
},
}
err := payload.Validate(httptest.NewRequest("PUT", "/settings", nil))
if tc.wantErr && err == nil {
t.Errorf("Validate(%q) = nil, want error", tc.timeout)
}
if !tc.wantErr && err != nil {
t.Errorf("Validate(%q) = %v, want nil", tc.timeout, err)
}
})
}
}

View File

@@ -11,6 +11,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/containerautomation"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker"
@@ -105,6 +106,7 @@ type Server struct {
KubernetesDeployer portainer.KubernetesDeployer
HelmPackageManager libhelmtypes.HelmPackageManager
Scheduler *scheduler.Scheduler
ContainerAutomationService *containerautomation.Service
ShutdownTrigger context.CancelFunc
StackDeployer deployments.StackDeployer
UpgradeService upgrade.Service
@@ -238,6 +240,7 @@ func (server *Server) Start(ctx context.Context) error {
settingsHandler.LDAPService = server.LDAPService
settingsHandler.SnapshotService = server.SnapshotService
settingsHandler.SetupTokenRequired = server.SetupToken != ""
settingsHandler.ContainerAutomationService = server.ContainerAutomationService
var sslHandler = sslhandler.NewHandler(requestBouncer)
sslHandler.SSLService = server.SSLService

View File

@@ -500,6 +500,11 @@ type (
EnableGPUManagement bool `json:"EnableGPUManagement,omitempty"`
// ContainerAutomationDisabled opts this environment out of native container
// automation (auto-heal / auto-update) regardless of the global switch (M5).
// The zero value participates, preserving behavior for existing environments.
ContainerAutomationDisabled bool `json:"ContainerAutomationDisabled,omitempty"`
// Deprecated fields
// Deprecated in DBVersion == 4
TLS bool `json:"TLS,omitempty" swaggerignore:"true"`
@@ -1151,6 +1156,33 @@ type (
AsyncMode bool `json:"AsyncMode,omitempty" example:"false"`
}
// ContainerAutoHealSettings holds the native auto-heal settings.
ContainerAutoHealSettings struct {
Enabled bool `json:"Enabled"`
CheckInterval string `json:"CheckInterval" example:"30s"`
Scope string `json:"Scope" example:"labeled"` // "labeled" | "all"
}
// ContainerAutoUpdateSettings holds the native auto-update settings.
ContainerAutoUpdateSettings struct {
Enabled bool `json:"Enabled"`
PollInterval string `json:"PollInterval" example:"6h"`
Scope string `json:"Scope" example:"labeled"` // "labeled" | "all"
Cleanup bool `json:"Cleanup"` // remove dangling old images after a standalone update
// RollbackOnFailure health-gates a standalone update: if the new
// container does not become healthy within RollbackTimeout, it is
// recreated back on the previous image. Standalone-only (M5).
RollbackOnFailure bool `json:"RollbackOnFailure"`
RollbackTimeout string `json:"RollbackTimeout" example:"120s"`
}
// ContainerAutomationSettings holds native container automation settings
// (auto-heal and auto-update).
ContainerAutomationSettings struct {
AutoHeal ContainerAutoHealSettings `json:"AutoHeal"`
AutoUpdate ContainerAutoUpdateSettings `json:"AutoUpdate"`
}
// Settings represents the application settings
Settings struct {
// URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string
@@ -1211,6 +1243,9 @@ type (
// ForceSecureCookies forces the Secure attribute on auth cookies regardless of detected scheme.
// Enable when Portainer runs behind a TLS-terminating proxy.
ForceSecureCookies bool `json:"ForceSecureCookies" example:"false"`
// ContainerAutomation holds native container automation settings.
ContainerAutomation ContainerAutomationSettings `json:"ContainerAutomation"`
}
// SnapshotJob represents a scheduled job that can create environment(endpoint) snapshots

View File

@@ -14,6 +14,7 @@ import (
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/stackutils"
@@ -236,6 +237,36 @@ func redeployWhenChangedSecondStage(
return nil
}
// ResolveStackRegistries resolves the registries to use when redeploying a stack
// from a userless/system context such as the auto-update daemon. It mirrors the
// git redeploy path (redeployWhenChangedSecondStage): registries are scoped to
// the stack author's access on the endpoint via getUserRegistries, then ECR
// tokens are refreshed and persisted (matching the deployment config layer) so
// the redeploy authenticates with fresh credentials. Returns StackAuthorMissingErr
// when the stack author no longer exists, like the git path.
func ResolveStackRegistries(datastore dataservices.DataStore, stack *portainer.Stack, endpointID portainer.EndpointID) ([]portainer.Registry, error) {
author := cmp.Or(stack.UpdatedBy, stack.CreatedBy)
user, err := datastore.User().UserByUsername(author)
if err != nil {
return nil, &StackAuthorMissingErr{int(stack.ID), author}
}
registries, err := getUserRegistries(datastore, user, endpointID)
if err != nil {
return nil, err
}
if err := datastore.UpdateTx(func(tx dataservices.DataStoreTx) error {
registryutils.RefreshAndPersistECRTokens(tx, registries)
return nil
}); err != nil {
return nil, errors.WithMessage(err, "failed to refresh ECR registry tokens")
}
return registries, nil
}
func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, endpointID portainer.EndpointID) ([]portainer.Registry, error) {
registries, err := datastore.Registry().ReadAll()
if err != nil {

View File

@@ -99,6 +99,8 @@
--orange-1: #e86925;
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
--text-log-viewer-color-json-yellow: var(--text-log-viewer-color);
@@ -267,6 +269,8 @@
/* Dark Theme */
[theme='dark'] {
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
--text-log-viewer-color-json-yellow: var(--text-log-viewer-color);
@@ -436,6 +440,7 @@
/* High Contrast Theme */
[theme='highcontrast'] {
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
--text-log-viewer-color-json-yellow: var(--text-log-viewer-color);

View File

@@ -418,7 +418,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
var stack = {
name: 'docker.stacks.stack',
url: '/:name?stackId&type&regular&external&orphaned&orphanedRunning&tab',
url: '/:name?id&type&regular&external&orphaned&orphanedRunning&tab',
views: {
'content@': {
component: 'stackItemView',
@@ -436,65 +436,6 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
},
};
// Stack-scoped container attribute sub-tabs. These mirror the global
// docker.containers.container.* states but live under the stack tree so the
// inherited stack params (name/stackId/type/...) are preserved and the
// breadcrumb keeps the stack trail (Stacks > stack > container > tab) when a
// container is opened from a stack.
var stackContainerAttach = {
name: 'docker.stacks.stack.container.attach',
url: '/attach',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/console/attach.html',
controller: 'ContainerConsoleController',
},
},
};
var stackContainerExec = {
name: 'docker.stacks.stack.container.exec',
url: '/exec',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/console/exec.html',
controller: 'ContainerConsoleController',
},
},
};
var stackContainerInspect = {
name: 'docker.stacks.stack.container.inspect',
url: '/inspect',
views: {
'content@': {
component: 'dockerContainerInspectView',
},
},
};
var stackContainerLogs = {
name: 'docker.stacks.stack.container.logs',
url: '/logs',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/logs/containerlogs.html',
controller: 'ContainerLogsController',
},
},
};
var stackContainerStats = {
name: 'docker.stacks.stack.container.stats',
url: '/stats',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/stats/containerstats.html',
controller: 'ContainerStatsController',
},
},
};
var stackCreation = {
name: 'docker.stacks.newstack',
url: '/newstack',
@@ -728,11 +669,6 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
$stateRegistryProvider.register(stacks);
$stateRegistryProvider.register(stack);
$stateRegistryProvider.register(stackContainer);
$stateRegistryProvider.register(stackContainerAttach);
$stateRegistryProvider.register(stackContainerExec);
$stateRegistryProvider.register(stackContainerInspect);
$stateRegistryProvider.register(stackContainerLogs);
$stateRegistryProvider.register(stackContainerStats);
$stateRegistryProvider.register(stackCreation);
$stateRegistryProvider.register(swarm);
$stateRegistryProvider.register(swarmVisualizer);

View File

@@ -9,6 +9,7 @@ import { DockerfileDetails } from '@/react/docker/images/ItemView/DockerfileDeta
import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus';
import { GpusList } from '@/react/docker/host/SetupView/GpusList';
import { InsightsBox } from '@/react/components/InsightsBox';
import { BetaAlert } from '@/react/portainer/environments/update-schedules/common/BetaAlert';
import { EventsDatatable } from '@/react/docker/events/EventsDatatables';
import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser';
import { AgentVolumeBrowser } from '@/react/docker/volumes/BrowseView/AgentVolumeBrowser';
@@ -58,6 +59,7 @@ const ngModule = angular
'className',
])
)
.component('betaAlert', r2a(BetaAlert, ['className', 'message', 'isHtml']))
.component(
'agentHostBrowserReact',
r2a(withUIRouter(withCurrentUser(AgentHostBrowser)), [

View File

@@ -1,4 +1,14 @@
<page-header title="'Container console'" breadcrumbs="breadcrumbs"> </page-header>
<page-header
title="'Container console'"
breadcrumbs="[
{ label:'Containers', link:'docker.containers' },
{
label:(container.Name | trimcontainername),
link: 'docker.containers.container',
linkParams: { id: container.Id },
}, 'Console']"
>
</page-header>
<div class="row" ng-init="autoconnectAttachView()" ng-show="loaded">
<div class="col-lg-12 col-md-12 col-xs-12">

View File

@@ -1,7 +1,5 @@
import { baseHref } from '@/portainer/helpers/pathHelper';
import { commandStringToArray } from '@/docker/helpers/containers';
import { trimContainerName } from '@/docker/filters/utils';
import { getContainerSubTabBreadcrumbs } from '@/react/docker/containers/ItemView/containerBreadcrumbs';
import { isLinuxTerminalCommand, LINUX_SHELL_INIT_COMMANDS } from '@@/Terminal/Terminal';
angular.module('portainer.docker').controller('ContainerConsoleController', [
@@ -128,15 +126,9 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
$scope.initView = function () {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
// Set the trail up-front (without the container name) so it survives the
// load window and a load error; the success path fills in the name.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), '', 'Console');
return ContainerService.container(endpoint.Id, $transition$.params().id)
.then(function (data) {
$scope.container = data;
// Stack-aware breadcrumb: keeps the stack trail when the container was
// opened from a stack, falls back to the global Containers trail otherwise.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), trimContainerName(data.Name), 'Console');
return ImageService.image(data.Image);
})
.then(function (data) {

View File

@@ -1,4 +1,14 @@
<page-header title="'Container console'" breadcrumbs="breadcrumbs"> </page-header>
<page-header
title="'Container console'"
breadcrumbs="[
{ label:'Containers', link:'docker.containers' },
{
label:(container.Name | trimcontainername),
link: 'docker.containers.container',
linkParams: { id: container.Id },
}, 'Console']"
>
</page-header>
<div class="row" ng-init="initView()" ng-show="loaded">
<div class="col-lg-12 col-md-12 col-xs-12">

View File

@@ -1,8 +1,5 @@
import moment from 'moment';
import { trimContainerName } from '@/docker/filters/utils';
import { getContainerSubTabBreadcrumbs } from '@/react/docker/containers/ItemView/containerBreadcrumbs';
angular.module('portainer.docker').controller('ContainerLogsController', [
'$scope',
'$transition$',
@@ -84,16 +81,10 @@ angular.module('portainer.docker').controller('ContainerLogsController', [
function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
// Set the trail up-front (without the container name) so it survives the
// load window and a load error; the success path fills in the name.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), '', 'Logs');
ContainerService.container(endpoint.Id, $transition$.params().id)
.then(function success(data) {
var container = data;
$scope.container = container;
// Stack-aware breadcrumb: keeps the stack trail when the container was
// opened from a stack, falls back to the global Containers trail otherwise.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), trimContainerName(container.Name), 'Logs');
const logsEnabled = container.HostConfig && container.HostConfig.LogConfig && container.HostConfig.LogConfig.Type && container.HostConfig.LogConfig.Type !== 'none';
$scope.logsEnabled = logsEnabled;

View File

@@ -1,4 +1,14 @@
<page-header title="'Container logs'" breadcrumbs="breadcrumbs"> </page-header>
<page-header
title="'Container logs'"
breadcrumbs="[
{ label:'Containers', link:'docker.containers' },
{
label:(container.Name | trimcontainername),
link: 'docker.containers.container',
linkParams: { id: container.Id }
}, 'Logs']"
>
</page-header>
<container-log-view ng-if="!logsEnabled"></container-log-view>

View File

@@ -1,8 +1,5 @@
import moment from 'moment';
import { trimContainerName } from '@/docker/filters/utils';
import { getContainerSubTabBreadcrumbs } from '@/react/docker/containers/ItemView/containerBreadcrumbs';
angular.module('portainer.docker').controller('ContainerStatsController', [
'$q',
'$scope',
@@ -162,15 +159,9 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
// Set the trail up-front (without the container name) so it survives the
// load window and a load error; the success path fills in the name.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), '', 'Stats');
ContainerService.container(endpoint.Id, $transition$.params().id)
.then(function success(data) {
$scope.container = data;
// Stack-aware breadcrumb: keeps the stack trail when the container was
// opened from a stack, falls back to the global Containers trail otherwise.
$scope.breadcrumbs = getContainerSubTabBreadcrumbs($transition$.to().name, $transition$.params(), trimContainerName(data.Name), 'Stats');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve container information');

View File

@@ -1,4 +1,14 @@
<page-header title="'Container statistics'" breadcrumbs="breadcrumbs"> </page-header>
<page-header
title="'Container statistics'"
breadcrumbs="[
{ label:'Containers', link:'docker.containers' },
{
label:(container.Name | trimcontainername),
link: 'docker.containers.container',
linkParams: { id: container.Id },
}, 'Stats']"
>
</page-header>
<div class="row">
<div class="col-md-12">

View File

@@ -1,3 +1,5 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
export default class DockerFeaturesConfigurationController {
/* @ngInject */
constructor($async, $scope, $state, EndpointService, SettingsService, Notifications, StateManager) {
@@ -9,6 +11,9 @@ export default class DockerFeaturesConfigurationController {
this.Notifications = Notifications;
this.StateManager = StateManager;
this.limitedFeatureAutoUpdate = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
this.limitedFeatureUpToDateImage = FeatureId.IMAGE_UP_TO_DATE_INDICATOR;
this.formValues = {
enableHostManagementFeatures: false,
allowVolumeBrowserForRegularUsers: false,

View File

@@ -52,6 +52,7 @@
name="'disableSysctlSettingForRegularUsers'"
label="'Enable Change Window'"
label-class="'col-sm-7 col-lg-4'"
feature-id="$ctrl.limitedFeatureAutoUpdate"
tooltip="'Specify a time-frame during which GitOps updates can occur in this environment.'"
on-change="($ctrl.onToggleAutoUpdate)"
>
@@ -201,6 +202,7 @@
checked="false"
name="'outOfDateImageToggle'"
label-class="'col-sm-7 col-lg-4'"
feature-id="$ctrl.limitedFeatureUpToDateImage"
></por-switch-field>
</div>
</div>

View File

@@ -128,6 +128,27 @@ angular
},
};
$stateRegistryProvider.register({
name: 'edge.devices',
url: '/devices',
abstract: true,
});
if (process.env.PORTAINER_EDITION === 'BE') {
$stateRegistryProvider.register({
name: 'edge.devices.waiting-room',
url: '/waiting-room',
views: {
'content@': {
component: 'waitingRoomView',
},
},
data: {
docs: '/user/edge/waiting-room',
},
});
}
$stateRegistryProvider.register({
name: 'edge.templates',
url: '/templates?template',

View File

@@ -1,13 +1,24 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { WaitingRoomView } from '@/react/edge/edge-devices/WaitingRoomView';
import { templatesModule } from './templates';
import { jobsModule } from './jobs';
import { stacksModule } from './edge-stacks';
import { groupsModule } from './groups';
export const viewsModule = angular.module('portainer.edge.react.views', [
templatesModule,
jobsModule,
stacksModule,
groupsModule,
]).name;
export const viewsModule = angular
.module('portainer.edge.react.views', [
templatesModule,
jobsModule,
stacksModule,
groupsModule,
])
.component(
'waitingRoomView',
r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), [])
).name;

10
app/global.d.ts vendored
View File

@@ -76,3 +76,13 @@ interface Window {
};
};
}
declare module 'process' {
global {
namespace NodeJS {
interface ProcessEnv {
PORTAINER_EDITION: 'BE' | 'CE';
}
}
}
}

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" ng-app="portainer" ng-strict-di>
<html lang="en" ng-app="portainer" ng-strict-di data-edition="<%= process.env.PORTAINER_EDITION %>">
<head>
<meta charset="utf-8" />
<title>Portainer</title>

View File

@@ -5,6 +5,9 @@ import './i18n';
import angular from 'angular';
import { UI_ROUTER_REACT_HYBRID } from '@uirouter/react-hybrid';
import { Edition } from '@/react/portainer/feature-flags/enums';
import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service';
import './agent';
import { azureModule } from './azure';
import './docker/__module';
@@ -27,6 +30,8 @@ if (window.origin == 'http://localhost:49000') {
document.getElementById('base').href = basePath;
}
initFeatureService(Edition[process.env.PORTAINER_EDITION]);
angular
.module('portainer', [
'ui.bootstrap',

View File

@@ -205,6 +205,10 @@
</div>
<!-- #end region IMAGE FIELD -->
<div class="col-sm-12 mb-4 !p-0">
<annotations-be-teaser></annotations-be-teaser>
</div>
<div ng-if="ctrl.formValues.ResourcePool">
<!-- #region STACK -->
<kube-stack-name

View File

@@ -76,6 +76,10 @@
</div>
<!-- !name -->
<div class="col-sm-12 !p-0">
<annotations-be-teaser></annotations-be-teaser>
</div>
<div ng-if="ctrl.formValues.ResourcePool">
<kubernetes-configuration-data
ng-if="ctrl.formValues"

View File

@@ -81,6 +81,10 @@
<rd-widget>
<rd-widget-body>
<form ng-if="!ctrl.isSystemConfig()" class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off">
<div class="col-sm-12 !p-0">
<annotations-be-teaser></annotations-be-teaser>
</div>
<kubernetes-configuration-data
ng-if="ctrl.formValues"
form-values="ctrl.formValues"

View File

@@ -77,6 +77,10 @@
</div>
<!-- !name -->
<div class="col-sm-12 !p-0">
<annotations-be-teaser></annotations-be-teaser>
</div>
<div ng-if="ctrl.formValues.ResourcePool">
<div class="col-sm-12 form-section-title"> Information </div>
<div class="form-group">

View File

@@ -32,6 +32,10 @@
<rd-widget>
<rd-widget-body>
<form ng-if="!ctrl.isSystemConfig()" class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off">
<div class="col-sm-12 !p-0">
<annotations-be-teaser></annotations-be-teaser>
</div>
<kubernetes-configuration-data
ng-if="ctrl.formValues"
form-values="ctrl.formValues"

View File

@@ -25,6 +25,7 @@
checked="formValues.enabled"
name="'disableSysctlSettingForRegularUsers'"
label="'Enable pod security constraints'"
feature-id="limitedFeaturePodSecurityPolicy"
label-class="'col-sm-3 col-lg-2 px-0'"
switch-class="'col-sm-8'"
>

View File

@@ -1,10 +1,12 @@
import angular from 'angular';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
angular.module('portainer.kubernetes').controller('KubernetesSecurityConstraintController', [
'$scope',
'EndpointProvider',
'EndpointService',
function ($scope, EndpointProvider, EndpointService) {
$scope.limitedFeaturePodSecurityPolicy = FeatureId.POD_SECURITY_POLICY_CONSTRAINT;
$scope.state = {
viewReady: false,
actionInProgress: false,

View File

@@ -1,3 +1,5 @@
import featureFlagModule from '@/react/portainer/feature-flags';
import './rbac';
import componentsModule from './components';
@@ -22,6 +24,7 @@ angular
'portainer.registrymanagement',
componentsModule,
settingsModule,
featureFlagModule,
userActivityModule,
servicesModule,
reactModule,
@@ -198,6 +201,19 @@ angular
},
};
const edgeAutoCreateScript = {
name: 'portainer.endpoints.edgeAutoCreateScript',
url: '/aeec',
views: {
'content@': {
component: 'edgeAutoCreateScriptView',
},
},
data: {
docs: '/admin/environments/aeec',
},
};
var endpointAccess = {
name: 'portainer.endpoints.endpoint.access',
url: '/access',
@@ -455,6 +471,7 @@ angular
$stateRegistryProvider.register(endpoints);
$stateRegistryProvider.register(endpoint);
$stateRegistryProvider.register(endpointAccess);
$stateRegistryProvider.register(edgeAutoCreateScript);
$stateRegistryProvider.register(groups);
$stateRegistryProvider.register(group);
$stateRegistryProvider.register(groupCreation);

View File

@@ -0,0 +1,24 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { getFeatureDetails } from '@@/BEFeatureIndicator/utils';
export default class BeIndicatorController {
limitedToBE?: boolean;
url?: string;
feature?: FeatureId;
/* @ngInject */
constructor() {
this.limitedToBE = false;
this.url = '';
}
$onInit() {
const { url, limitedToBE } = getFeatureDetails(this.feature);
this.limitedToBE = limitedToBE;
this.url = url;
}
}

View File

@@ -0,0 +1,5 @@
<a class="vertical-center be-indicator ml-5" href="{{ $ctrl.url }}" target="_blank" rel="noopener" ng-if="$ctrl.limitedToBE">
<ng-transclude></ng-transclude>
<pr-icon icon="'briefcase'" class-name="'space-right be-indicator-icon'"></pr-icon>
<span class="be-indicator-label">Business Feature</span>
</a>

View File

@@ -0,0 +1,10 @@
import controller from './BEFeatureIndicator.controller';
export const beFeatureIndicator = {
templateUrl: './BEFeatureIndicator.html',
controller,
bindings: {
feature: '<',
},
transclude: true,
};

View File

@@ -1,6 +1,13 @@
import { IComponentOptions, IComponentController, IScope } from 'angular';
import {
IComponentOptions,
IComponentController,
IFormController,
IScope,
} from 'angular';
class BoxSelectorController implements IComponentController {
formCtrl!: IFormController;
onChange!: (value: string | number) => void;
radioName!: string;
@@ -14,8 +21,9 @@ class BoxSelectorController implements IComponentController {
this.$scope = $scope;
}
handleChange(value: string | number) {
handleChange(value: string | number, limitedToBE: boolean) {
this.$scope.$evalAsync(() => {
this.formCtrl.$setValidity(this.radioName, !limitedToBE, this.formCtrl);
this.onChange(value);
});
}
@@ -38,5 +46,8 @@ export const BoxSelectorAngular: IComponentOptions = {
slim: '<',
label: '<',
},
require: {
formCtrl: '^form',
},
controller: BoxSelectorController,
};

View File

@@ -1,3 +1,5 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { BoxSelectorOption } from '@@/BoxSelector/types';
import { IconProps } from '@@/Icon';
@@ -6,7 +8,8 @@ export function buildOption<T extends number | string>(
icon: IconProps['icon'],
label: BoxSelectorOption<T>['label'],
description: BoxSelectorOption<T>['description'],
value: BoxSelectorOption<T>['value']
value: BoxSelectorOption<T>['value'],
feature?: FeatureId
): BoxSelectorOption<T> {
return { id, icon, label, description, value };
return { id, icon, label, description, value, feature };
}

View File

@@ -9,5 +9,6 @@ export const porAccessManagement = {
updateAccess: '<',
actionInProgress: '<',
filterUsers: '<',
limitedFeature: '<',
},
};

View File

@@ -28,10 +28,11 @@
class="form-control"
data-cy="access-management-role-select"
ng-model="ctrl.formValues.selectedRole"
ng-options="role as role.Name for role in ctrl.roles"
ng-options="role as ctrl.roleLabel(role) disable when ctrl.isRoleLimitedToBE(role) for role in ctrl.roles"
>
</select>
</div>
<be-feature-indicator feature="ctrl.limitedFeature" class="space-left"></be-feature-indicator>
</div>
</div>
</div>
@@ -65,8 +66,13 @@
<access-datatable
ng-if="ctrl.authorizedUsersAndTeams"
table-key="'access_' + ctrl.entityType"
show-warning="ctrl.entityType !== 'registry'"
is-update-enabled="ctrl.entityType !== 'registry'"
show-roles="ctrl.entityType !== 'registry'"
roles="ctrl.roles"
inherit-from="ctrl.inheritFrom"
dataset="ctrl.authorizedUsersAndTeams"
on-update="(ctrl.updateAction)"
on-remove="(ctrl.unauthorizeAccess)"
>
</access-datatable>

View File

@@ -1,14 +1,19 @@
import _ from 'lodash-es';
import angular from 'angular';
import { RoleTypes } from '@/portainer/rbac/models/role';
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
class PorAccessManagementController {
/* @ngInject */
constructor($scope, $state, Notifications, AccessService, RoleService) {
Object.assign(this, { $scope, $state, Notifications, AccessService, RoleService });
this.limitedToBE = false;
this.$state = $state;
this.unauthorizeAccess = this.unauthorizeAccess.bind(this);
this.updateAction = this.updateAction.bind(this);
this.onChangeUsersAndTeams = this.onChangeUsersAndTeams.bind(this);
}
@@ -18,6 +23,17 @@ class PorAccessManagementController {
});
}
updateAction(updatedUserAccesses, updatedTeamAccesses) {
const entity = this.accessControlledEntity;
const oldUserAccessPolicies = entity.UserAccessPolicies;
const oldTeamAccessPolicies = entity.TeamAccessPolicies;
const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, updatedUserAccesses, updatedTeamAccesses);
this.accessControlledEntity.UserAccessPolicies = accessPolicies.userAccessPolicies;
this.accessControlledEntity.TeamAccessPolicies = accessPolicies.teamAccessPolicies;
this.updateAccess();
}
authorizeAccess() {
const entity = this.accessControlledEntity;
const oldUserAccessPolicies = entity.UserAccessPolicies;
@@ -44,8 +60,32 @@ class PorAccessManagementController {
this.updateAccess();
}
isRoleLimitedToBE(role) {
if (!this.limitedToBE) {
return false;
}
return role.ID !== RoleTypes.STANDARD;
}
roleLabel(role) {
if (!this.limitedToBE) {
return role.Name;
}
if (this.isRoleLimitedToBE(role)) {
return `${role.Name} (Business Feature)`;
}
return `${role.Name} (Default)`;
}
async $onInit() {
try {
if (this.limitedFeature) {
this.limitedToBE = isLimitedToBE(this.limitedFeature);
}
const entity = this.accessControlledEntity;
const parent = this.inheritFrom;
@@ -53,7 +93,7 @@ class PorAccessManagementController {
this.roles = _.orderBy(roles, 'Priority', 'asc');
this.formValues = {
multiselectOutput: [],
selectedRole: this.roles[0],
selectedRole: this.roles.find((role) => !this.isRoleLimitedToBE(role)),
};
const data = await this.AccessService.accesses(entity, parent, this.roles);

View File

@@ -5,10 +5,12 @@ import porAccessManagementModule from './accessManagement';
import widgetModule from './widget';
import { boxSelectorModule } from './BoxSelector';
import { beFeatureIndicator } from './BEFeatureIndicator';
import { InformationPanelAngular } from './InformationPanel';
import { gitFormModule } from './forms/git-form';
import { tlsFieldsetModule } from './tls-fieldset';
export default angular
.module('portainer.app.components', [boxSelectorModule, widgetModule, gitFormModule, porAccessManagementModule, formComponentsModule, tlsFieldsetModule])
.component('informationPanel', InformationPanelAngular).name;
.component('informationPanel', InformationPanelAngular)
.component('beFeatureIndicator', beFeatureIndicator).name;

View File

@@ -1,4 +1,9 @@
import { baseHref } from '@/portainer/helpers/pathHelper';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { ModalType } from '@@/modals';
import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import providers, { getProviderByUrl } from './providers';
const MS_TENANT_ID_PLACEHOLDER = 'TENANT_ID';
@@ -8,6 +13,9 @@ export default class OAuthSettingsController {
constructor($scope, $async) {
Object.assign(this, { $scope, $async });
this.limitedFeature = FeatureId.HIDE_INTERNAL_AUTH;
this.limitedFeatureClass = 'limited-be';
this.state = {
provider: 'custom',
overrideConfiguration: false,
@@ -19,6 +27,9 @@ export default class OAuthSettingsController {
this.onMicrosoftTenantIDChange = this.onMicrosoftTenantIDChange.bind(this);
this.useDefaultProviderConfiguration = this.useDefaultProviderConfiguration.bind(this);
this.updateSSO = this.updateSSO.bind(this);
this.addTeamMembershipMapping = this.addTeamMembershipMapping.bind(this);
this.removeTeamMembership = this.removeTeamMembership.bind(this);
this.onToggleAutoTeamMembership = this.onToggleAutoTeamMembership.bind(this);
this.onChangeAuthStyle = this.onChangeAuthStyle.bind(this);
this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this);
}
@@ -43,16 +54,21 @@ export default class OAuthSettingsController {
this.state.overrideConfiguration = false;
this.settings.AuthorizationURI = provider.authUrl;
this.settings.AccessTokenURI = provider.accessTokenUrl;
this.settings.ResourceURI = provider.resourceUrl;
this.settings.LogoutURI = provider.logoutUrl;
this.settings.UserIdentifier = provider.userIdentifier;
this.settings.Scopes = provider.scopes;
this.settings.AuthStyle = provider.authStyle;
if (!this.isLimitedToBE || providerId === 'custom') {
this.settings.AuthorizationURI = provider.authUrl;
this.settings.AccessTokenURI = provider.accessTokenUrl;
this.settings.ResourceURI = provider.resourceUrl;
this.settings.LogoutURI = provider.logoutUrl;
this.settings.UserIdentifier = provider.userIdentifier;
this.settings.Scopes = provider.scopes;
this.settings.AuthStyle = provider.authStyle;
if (providerId === 'microsoft' && this.state.microsoftTenantID !== '') {
this.onMicrosoftTenantIDChange();
if (providerId === 'microsoft' && this.state.microsoftTenantID !== '') {
this.onMicrosoftTenantIDChange();
}
} else {
this.settings.ClientID = '';
this.settings.ClientSecret = '';
}
}
@@ -65,6 +81,7 @@ export default class OAuthSettingsController {
updateSSO(checked) {
this.$scope.$evalAsync(() => {
this.settings.SSO = checked;
this.settings.HideInternalAuth = false;
});
}
@@ -74,7 +91,64 @@ export default class OAuthSettingsController {
});
}
async onChangeHideInternalAuth(checked) {
this.$async(async () => {
if (this.isLimitedToBE) {
return;
}
if (checked) {
const confirmed = await confirm({
title: 'Hide internal authentication prompt',
message: 'By hiding internal authentication prompt, you will only be able to login via SSO. Are you sure?',
confirmButton: buildConfirmButton('Confirm', 'danger'),
modalType: ModalType.Warn,
});
if (!confirmed) {
return;
}
}
this.settings.HideInternalAuth = checked;
});
}
onToggleAutoTeamMembership(checked) {
this.$scope.$evalAsync(() => {
this.settings.OAuthAutoMapTeamMemberships = checked;
});
}
addTeamMembershipMapping() {
this.settings.TeamMemberships.OAuthClaimMappings.push({ ClaimValRegex: '', Team: this.settings.DefaultTeamID });
}
removeTeamMembership(index) {
this.settings.TeamMemberships.OAuthClaimMappings.splice(index, 1);
}
isOAuthTeamMembershipFormValid() {
if (this.settings.OAuthAutoMapTeamMemberships && this.settings.TeamMemberships) {
if (!this.settings.TeamMemberships.OAuthClaimName) {
return false;
}
const hasInvalidMapping = this.settings.TeamMemberships.OAuthClaimMappings.some((m) => !(m.ClaimValRegex && m.Team));
if (hasInvalidMapping) {
return false;
}
}
return true;
}
$onInit() {
this.isLimitedToBE = isLimitedToBE(this.limitedFeature);
if (this.isLimitedToBE) {
return;
}
if (this.settings.RedirectURI === '') {
this.settings.RedirectURI = window.location.origin + baseHref();
}
@@ -95,5 +169,13 @@ export default class OAuthSettingsController {
if (this.settings.DefaultTeamID === 0) {
this.settings.DefaultTeamID = null;
}
if (this.settings.TeamMemberships == null) {
this.settings.TeamMemberships = {};
}
if (this.settings.TeamMemberships.OAuthClaimMappings === null) {
this.settings.TeamMemberships.OAuthClaimMappings = [];
}
}
}

View File

@@ -15,6 +15,20 @@
</div>
<!-- !SSO -->
<!-- HideInternalAuth -->
<div class="form-group" ng-if="$ctrl.settings.SSO">
<div class="col-sm-12">
<por-switch-field
label="'Hide internal authentication prompt'"
name="'hide-internal-auth'"
feature-id="$ctrl.limitedFeature"
checked="$ctrl.settings.HideInternalAuth"
on-change="($ctrl.onChangeHideInternalAuth)"
></por-switch-field>
</div>
</div>
<!-- !HideInternalAuth -->
<auto-user-provision-toggle
value="$ctrl.settings.OAuthAutoCreateUsers"
on-change="($ctrl.onAutoUserProvisionChange)"
@@ -38,9 +52,16 @@
</span>
<div class="col-sm-9" ng-if="$ctrl.teams.length > 0">
<div class="col-sm-12 small text-muted">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
The default team option will be disabled when automatic team membership is enabled
</p>
</div>
<div class="col-xs-12 vertical-center">
<select
class="form-control"
ng-disabled="$ctrl.settings.OAuthAutoMapTeamMemberships"
ng-model="$ctrl.settings.DefaultTeamID"
ng-options="team.Id as team.Name for team in $ctrl.teams"
data-cy="default-team-select"
@@ -51,7 +72,7 @@
type="button"
class="btn btn-md btn-danger"
ng-click="$ctrl.settings.DefaultTeamID = null"
ng-disabled="!$ctrl.settings.DefaultTeamID"
ng-disabled="!$ctrl.settings.DefaultTeamID || $ctrl.settings.OAuthAutoMapTeamMemberships"
ng-if="$ctrl.teams.length > 0"
>
<pr-icon icon="'x'" size="'md'"></pr-icon>
@@ -61,6 +82,99 @@
</div>
</div>
<div class="col-sm-12 form-section-title"> Team membership </div>
<div class="form-group">
<div class="col-sm-12 text-muted small"> Automatic team membership synchronizes the team membership based on a custom claim in the token from the OAuth provider. </div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
label="'Automatic team membership'"
name="'tls'"
feature-id="$ctrl.limitedFeature"
checked="$ctrl.settings.OAuthAutoMapTeamMemberships"
on-change="($ctrl.onToggleAutoTeamMembership)"
></por-switch-field>
</div>
</div>
<div ng-if="$ctrl.settings.OAuthAutoMapTeamMemberships">
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left" for="oauth_token_claim_name">
Claim name
<portainer-tooltip message="'The OpenID Connect UserInfo Claim name that contains the team identifier the user belongs to.'"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<div class="col-xs-11 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_token_claim_name"
ng-model="$ctrl.settings.TeamMemberships.OAuthClaimName"
placeholder="groups"
data-cy="oauth-token-claim-name"
/>
</div>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left"> Statically assigned teams </label>
<div class="col-sm-9 col-lg-10">
<span class="label label-default interactive vertical-center ml-4" ng-click="$ctrl.addTeamMembershipMapping()">
<pr-icon icon="'plus-circle'"></pr-icon>
add team mapping
</span>
<div class="col-sm-12 form-inline" ng-repeat="mapping in $ctrl.settings.TeamMemberships.OAuthClaimMappings" style="margin-top: 0.75em">
<div class="input-group input-group-sm col-sm-5">
<span class="input-group-addon">claim value regex</span>
<input type="text" class="form-control" ng-model="mapping.ClaimValRegex" data-cy="claim-value-regex" />
</div>
<span style="margin: 0px 0.5em">maps to</span>
<div class="input-group input-group-sm col-sm-3 col-lg-4">
<span class="input-group-addon">team</span>
<select
class="form-control"
data-cy="team-select"
ng-init="mapping.Team = mapping.Team || $ctrl.settings.DefaultTeamID"
ng-model="mapping.Team"
ng-options="team.Id as team.Name for team in $ctrl.teams"
>
<option selected value="">Select a team</option>
</select>
</div>
<button type="button" class="btn btn-md btn-danger" ng-click="$ctrl.removeTeamMembership($index)">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>
<div>
<div class="small text-warning vertical-center mt-1" ng-show="!mapping.ClaimValRegex">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Claim value regex is required.
</div>
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 text-muted small" style="margin-bottom: 0.5em"> The default team will be assigned when the user does not belong to any other team </div>
<label class="col-sm-3 col-lg-2 control-label text-left">Default team</label>
<span class="small text-muted" style="margin-left: 20px" ng-if="$ctrl.teams.length === 0">
You have not yet created any teams. Head over to the <a ui-sref="portainer.teams">Teams view</a> to manage teams.
</span>
<div class="col-sm-9" ng-if="$ctrl.teams.length > 0">
<div class="col-xs-11">
<select class="form-control" ng-model="$ctrl.settings.DefaultTeamID" ng-options="team.Id as team.Name for team in $ctrl.teams" data-cy="default-team-select">
<option value="">No team</option>
</select>
</div>
</div>
</div>
</div>
<oauth-providers-selector on-change="($ctrl.onSelectProvider)" value="$ctrl.state.provider"></oauth-providers-selector>
<div ng-if="$ctrl.state.provider == 'custom' || $ctrl.state.overrideConfiguration">
@@ -77,7 +191,9 @@
id="oauth_client_id"
ng-model="$ctrl.settings.ClientID"
placeholder="xxxxxxxxxxxxxxxxxxxx"
class="form-control"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
tabindex="{{ $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' ? -1 : 0 }}"
/>
</div>
</div>
@@ -92,6 +208,9 @@
ng-model="$ctrl.settings.ClientSecret"
placeholder="xxxxxxxxxxxxxxxxxxxx"
autocomplete="new-password"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
tabindex="{{ $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' ? -1 : 0 }}"
/>
</div>
</div>
@@ -109,6 +228,8 @@
id="oauth_authorization_uri"
ng-model="$ctrl.settings.AuthorizationURI"
placeholder="https://example.com/oauth/authorize"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@@ -126,6 +247,8 @@
id="oauth_access_token_uri"
ng-model="$ctrl.settings.AccessTokenURI"
placeholder="https://example.com/oauth/token"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@@ -143,6 +266,8 @@
id="oauth_resource_uri"
ng-model="$ctrl.settings.ResourceURI"
placeholder="https://example.com/user"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@@ -162,6 +287,8 @@
id="oauth_redirect_uri"
ng-model="$ctrl.settings.RedirectURI"
placeholder="http://yourportainer.com/"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@@ -180,6 +307,8 @@
class="form-control"
id="oauth_logout_url"
ng-model="$ctrl.settings.LogoutURI"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@@ -199,6 +328,8 @@
id="oauth_user_identifier"
ng-model="$ctrl.settings.UserIdentifier"
placeholder="id"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@@ -218,6 +349,8 @@
id="oauth_scopes"
ng-model="$ctrl.settings.Scopes"
placeholder="id,email,name"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
@@ -226,9 +359,97 @@
<save-auth-settings-button
on-save-settings="($ctrl.onSaveSettings)"
save-button-state="($ctrl.saveButtonState)"
save-button-disabled="oauthSettingsForm.$invalid"
save-button-disabled="!$ctrl.isOAuthTeamMembershipFormValid() || oauthSettingsForm.$invalid"
limited-feature-id="$ctrl.limitedFeature"
limited-feature-class="$ctrl.limitedFeatureClass"
class-name="'oauth-save-settings-button'"
></save-auth-settings-button>
</div>
<div ng-if="$ctrl.state.provider != 'custom'" class="limited-be be-indicator-container">
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
<div class="limited-be-content">
<div class="col-sm-12 form-section-title">OAuth Configuration</div>
<div class="form-group" ng-if="$ctrl.state.provider == 'microsoft'">
<label for="oauth_microsoft_tenant_id" class="col-sm-3 col-lg-2 control-label text-left">
Tenant ID
<portainer-tooltip message="'ID of the Azure Directory you wish to authenticate against. Also known as the Directory ID'"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="oauth-microsoft-tenant-id"
class="form-control"
id="oauth_microsoft_tenant_id"
placeholder="xxxxxxxxxxxxxxxxxxxx"
ng-model="$ctrl.state.microsoftTenantID"
ng-change="$ctrl.onMicrosoftTenantIDChange()"
limited-feature-dir="{{::$ctrl.limitedFeature}}"
limited-feature-class="limited-be"
limited-feature-disabled
limited-feature-tabindex="-1"
required
/>
</div>
</div>
<div class="form-group">
<label for="oauth_client_id" class="col-sm-3 col-lg-2 control-label text-left">
{{ $ctrl.state.provider == 'microsoft' ? 'Application ID' : 'Client ID' }}
<portainer-tooltip message="'Public identifier of the OAuth application'"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="oauth-client-id"
id="oauth_client_id"
ng-model="$ctrl.settings.ClientID"
placeholder="xxxxxxxxxxxxxxxxxxxx"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
tabindex="{{ $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' ? -1 : 0 }}"
/>
</div>
</div>
<div class="form-group">
<label for="oauth_client_secret" class="col-sm-3 col-lg-2 control-label text-left"> {{ $ctrl.state.provider == 'microsoft' ? 'Application key' : 'Client secret' }} </label>
<div class="col-sm-9 col-lg-10">
<input
type="password"
class="form-control"
id="oauth_client_secret"
ng-model="$ctrl.settings.ClientSecret"
placeholder="*******"
autocomplete="new-password"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
tabindex="{{ $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' ? -1 : 0 }}"
/>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<a class="small interactive vertical-center" ng-if="!$ctrl.state.overrideConfiguration" ng-click="$ctrl.state.overrideConfiguration = true;">
<pr-icon icon="'wrench'"></pr-icon>
Override default configuration
</a>
<a class="small interactive vertical-center" ng-if="$ctrl.state.overrideConfiguration" ng-click="$ctrl.useDefaultProviderConfiguration($ctrl.state.provider)">
<pr-icon icon="'settings'"></pr-icon>
Use default configuration
</a>
</div>
</div>
<save-auth-settings-button
on-save-settings="($ctrl.onSaveSettings)"
save-button-state="($ctrl.saveButtonState)"
save-button-disabled="!$ctrl.isOAuthTeamMembershipFormValid() || oauthSettingsForm.$invalid"
limited-feature-id="$ctrl.limitedFeature"
limited-feature-class="$ctrl.limitedFeatureClass"
class-name="'oauth-save-settings-button'"
></save-auth-settings-button>
</div>
</div>
</ng-form>

View File

@@ -0,0 +1,69 @@
import _ from 'lodash-es';
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
export default class AccessViewerController {
/* @ngInject */
constructor($scope, Notifications, UserService, TeamMembershipService, Authentication) {
this.$scope = $scope;
this.Notifications = Notifications;
this.UserService = UserService;
this.TeamMembershipService = TeamMembershipService;
this.Authentication = Authentication;
this.limitedFeature = 'rbac-roles';
this.users = [];
this.selectedUserId = null;
this.onUserSelect = this.onUserSelect.bind(this);
}
onUserSelect(selectedUserId) {
this.$scope.$evalAsync(() => {
this.selectedUserId = selectedUserId;
});
}
// for admin, returns all users
// for team leader, only return all his/her team member users
async teamMemberUsers(users, teamMemberships) {
if (this.isAdmin) {
return users;
}
const filteredUsers = [];
const userId = this.Authentication.getUserDetails().ID;
const leadingTeams = await this.UserService.userLeadingTeams(userId);
const isMember = (userId, teamId) => {
return !!_.find(teamMemberships, { UserId: userId, TeamId: teamId });
};
for (const user of users) {
for (const leadingTeam of leadingTeams) {
if (isMember(user.Id, leadingTeam.Id)) {
filteredUsers.push(user);
break;
}
}
}
return filteredUsers;
}
async $onInit() {
try {
if (isLimitedToBE(this.limitedFeature)) {
return;
}
this.isAdmin = this.Authentication.isAdmin();
const allUsers = await this.UserService.users();
const teamMemberships = await this.TeamMembershipService.memberships();
const teamUsers = await this.teamMemberUsers(allUsers, teamMemberships);
this.users = teamUsers.map((user) => ({ label: user.Username, value: user.Id }));
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve users');
}
}
}

View File

@@ -0,0 +1,26 @@
<div class="col-sm-12 !mb-4">
<div class="be-indicator-container limited-be">
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
<div class="limited-be-content">
<rd-widget>
<rd-widget-header icon="user-x">
<header-title> Effective access viewer </header-title>
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="col-sm-12 form-section-title"> User </div>
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted" ng-if="$ctrl.users.length === 0"> No user available </span>
<por-select ng-if="$ctrl.users.length > 0" value="$ctrl.selectedUserId" options="$ctrl.users" on-change="($ctrl.onUserSelect)" placeholder="'Select a user'">
</por-select>
</div>
</div>
<effective-access-viewer user-id="$ctrl.selectedUserId"></effective-access-viewer>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View File

@@ -0,0 +1,6 @@
import controller from './access-viewer.controller';
export const accessViewer = {
templateUrl: './access-viewer.html',
controller,
};

View File

@@ -1,5 +1,6 @@
import { AccessHeaders } from '../authorization-guard';
import { rolesView } from './views/roles';
import { accessViewer } from './components/access-viewer';
import { RoleService } from './services/role.service';
import { RolesFactory } from './services/role.rest';
@@ -7,6 +8,7 @@ import { RolesFactory } from './services/role.rest';
angular
.module('portainer.rbac', ['ngResource'])
.constant('API_ENDPOINT_ROLES', 'api/roles')
.component('accessViewer', accessViewer)
.component('rolesView', rolesView)
.factory('RoleService', RoleService)
.factory('Roles', RolesFactory)

View File

@@ -1,3 +1,7 @@
<page-header title="'Roles'" breadcrumbs="['Role management']" reload="true"> </page-header>
<rbac-roles-datatable dataset="$ctrl.roles"></rbac-roles-datatable>
<div class="row">
<access-viewer> </access-viewer>
</div>

View File

@@ -4,6 +4,7 @@ import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser';
import { withFormValidation } from '@/react-tools/withFormValidation';
import { withControlledInput } from '@/react-tools/withControlledInput';
@@ -31,6 +32,7 @@ import { Terminal } from '@@/Terminal/Terminal';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { Slider } from '@@/form-components/Slider';
import { TagButton } from '@@/TagButton';
import { BETeaserButton } from '@@/BETeaserButton';
import { CodeEditor } from '@@/CodeEditor';
import { HelpLink } from '@@/HelpLink';
import { TextTip } from '@@/Tip/TextTip';
@@ -75,6 +77,18 @@ export const ngModule = angular
'errors',
])
)
.component(
'beTeaserButton',
r2a(BETeaserButton, [
'featureId',
'heading',
'message',
'buttonText',
'className',
'buttonClassName',
'data-cy',
])
)
.component(
'tagButton',
r2a(TagButton, ['value', 'label', 'title', 'onRemove'])
@@ -247,6 +261,7 @@ export const ngModule = angular
'inlineLoader',
r2a(InlineLoader, ['children', 'className', 'size'])
)
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []))
.component(
'shellTerminal',
r2a(Terminal, [

View File

@@ -12,8 +12,13 @@ export const rbacModule = angular
r2a(withUIRouter(withReactQuery(AccessDatatable)), [
'dataset',
'inheritFrom',
'isUpdateEnabled',
'onRemove',
'onUpdate',
'showRoles',
'showWarning',
'tableKey',
'isUpdatingAccess',
'isLoading',
])
).name;

View File

@@ -9,6 +9,7 @@ import { LDAPUsersTable } from '@/react/portainer/settings/AuthenticationView/LD
import { LDAPGroupsTable } from '@/react/portainer/settings/AuthenticationView/LDAPAuth/LDAPGroupsTable';
import { ApplicationSettingsPanel } from '@/react/portainer/settings/SettingsView/ApplicationSettingsPanel';
import { KubeSettingsPanel } from '@/react/portainer/settings/SettingsView/KubeSettingsPanel';
import { HelmCertPanel } from '@/react/portainer/settings/SettingsView/HelmCertPanel';
import { HiddenContainersPanel } from '@/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel';
import { SSLSettingsPanelWrapper } from '@/react/portainer/settings/SettingsView/SSLSettingsPanel/SSLSettingsPanel';
import { AuthStyleField } from '@/react/portainer/settings/AuthenticationView/OAuth';
@@ -39,7 +40,7 @@ export const settingsModule = angular
.component('ldapGroupsDatatable', r2a(LDAPGroupsTable, ['dataset']))
.component(
'ldapSettingsDnBuilder',
r2a(DnBuilder, ['value', 'onChange', 'suffix', 'label'])
r2a(DnBuilder, ['value', 'onChange', 'suffix', 'label', 'limitedFeatureId'])
)
.component(
'ldapSettingsGroupDnBuilder',
@@ -49,6 +50,7 @@ export const settingsModule = angular
'suffix',
'index',
'onRemoveClick',
'limitedFeatureId',
])
)
.component(
@@ -59,6 +61,7 @@ export const settingsModule = angular
'sslSettingsPanel',
r2a(withReactQuery(SSLSettingsPanelWrapper), [])
)
.component('helmCertPanel', r2a(withReactQuery(HelmCertPanel), []))
.component(
'hiddenContainersPanel',
r2a(withUIRouter(withReactQuery(HiddenContainersPanel)), [])
@@ -89,7 +92,12 @@ export const settingsModule = angular
)
.component(
'ldapSettingsTestLogin',
r2a(withReactQuery(LdapSettingsTestLogin), ['settings'])
r2a(withReactQuery(LdapSettingsTestLogin), [
'settings',
'limitedFeatureId',
'showBeIndicatorIfNeeded',
'isLimitedFeatureSelfContained',
])
)
.component(
'ldapSecurityFieldset',
@@ -98,6 +106,7 @@ export const settingsModule = angular
'onChange',
'errors',
'uploadState',
'limitedFeatureId',
'title',
])
)
@@ -118,6 +127,8 @@ export const settingsModule = angular
'onAutoPopulateChange',
'selectedAdminGroups',
'onSelectedAdminGroupsChange',
'limitedFeatureId',
'isLimitedFeatureSelfContained',
]
)
).name;

View File

@@ -13,6 +13,7 @@ export const switchField = r2a(SwitchField, [
'data-cy',
'disabled',
'onChange',
'featureId',
'switchClass',
'setTooltipHtmlMessage',
'valueExplanation',

View File

@@ -4,6 +4,7 @@ import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/portainer/environments/ListView';
import { EdgeAutoCreateScriptViewWrapper } from '@/react/portainer/environments/EdgeAutoCreateScriptView/EdgeAutoCreateScriptView';
import { ItemView } from '@/react/portainer/environments/ItemView/ItemView';
export const environmentsModule = angular
@@ -15,4 +16,8 @@ export const environmentsModule = angular
.component(
'environmentsItemView',
r2a(withUIRouter(withCurrentUser(ItemView)), [])
)
.component(
'edgeAutoCreateScriptView',
r2a(withUIRouter(withCurrentUser(EdgeAutoCreateScriptViewWrapper)), [])
).name;

View File

@@ -13,6 +13,7 @@ import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repos
import { wizardModule } from './wizard';
import { teamsModule } from './teams';
import { updateSchedulesModule } from './update-schedules';
import { environmentGroupModule } from './env-groups';
import { registriesModule } from './registries';
import { activityLogsModule } from './activity-logs';
@@ -25,6 +26,7 @@ export const viewsModule = angular
.module('portainer.app.react.views', [
wizardModule,
teamsModule,
updateSchedulesModule,
environmentGroupModule,
registriesModule,
activityLogsModule,

View File

@@ -0,0 +1,63 @@
import angular from 'angular';
import { StateRegistry } from '@uirouter/angularjs';
import { r2a } from '@/react-tools/react2angular';
import {
ListView,
CreateView,
ItemView,
} from '@/react/portainer/environments/update-schedules';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
export const updateSchedulesModule = angular
.module('portainer.edge.updateSchedules', [])
.component(
'updateSchedulesListView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
)
.component(
'updateSchedulesCreateView',
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateView))), [])
)
.component(
'updateSchedulesItemView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ItemView))), [])
)
.config(config).name;
function config($stateRegistryProvider: StateRegistry) {
$stateRegistryProvider.register({
name: 'portainer.endpoints.updateSchedules',
url: '/update-schedules',
views: {
'content@': {
component: 'updateSchedulesListView',
},
},
data: {
docs: '/admin/environments/update',
},
});
$stateRegistryProvider.register({
name: 'portainer.endpoints.updateSchedules.create',
url: '/update-schedules/new',
views: {
'content@': {
component: 'updateSchedulesCreateView',
},
},
});
$stateRegistryProvider.register({
name: 'portainer.endpoints.updateSchedules.item',
url: '/update-schedules/:id',
views: {
'content@': {
component: 'updateSchedulesItemView',
},
},
});
}

View File

@@ -0,0 +1,77 @@
import _ from 'lodash-es';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
export default class AdSettingsController {
/* @ngInject */
constructor(LDAPService, $scope) {
this.LDAPService = LDAPService;
this.$scope = $scope;
this.domainSuffix = '';
this.limitedFeatureId = FeatureId.HIDE_INTERNAL_AUTH;
this.onTlscaCertChange = this.onTlscaCertChange.bind(this);
this.searchUsers = this.searchUsers.bind(this);
this.searchGroups = this.searchGroups.bind(this);
this.parseDomainName = this.parseDomainName.bind(this);
this.onAccountChange = this.onAccountChange.bind(this);
this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this);
this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this);
}
onAutoUserProvisionChange(value) {
this.$scope.$evalAsync(() => {
this.settings.AutoCreateUsers = value;
});
}
parseDomainName(account) {
this.domainName = '';
if (!account || !account.includes('@')) {
return;
}
const [, domainName] = account.split('@');
if (!domainName) {
return;
}
const parts = _.compact(domainName.split('.'));
this.domainSuffix = parts.map((part) => `dc=${part}`).join(',');
}
onAccountChange(account) {
this.parseDomainName(account);
}
searchUsers() {
return this.LDAPService.users(this.settings);
}
searchGroups() {
return this.LDAPService.groups(this.settings);
}
onTlscaCertChange(file) {
this.tlscaCert = file;
}
addLDAPUrl() {
this.settings.URLs.push('');
}
removeLDAPUrl(index) {
this.settings.URLs.splice(index, 1);
}
isSaveSettingButtonDisabled() {
return isLimitedToBE(this.limitedFeatureId) || !this.isLdapFormValid();
}
$onInit() {
this.tlscaCert = this.settings.TLSCACert;
this.parseDomainName(this.settings.ReaderDN);
}
}

View File

@@ -0,0 +1,174 @@
<ng-form class="ad-settings" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-class="limited-be">
<div class="be-indicator-container">
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator></div>
<div class="limited-be-content">
<auto-user-provision-toggle
value="$ctrl.settings.AutoCreateUsers"
on-change="($ctrl.onAutoUserProvisionChange)"
description="'With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s). If disabled, users must be created in Portainer beforehand.'"
></auto-user-provision-toggle>
<div>
<div class="col-sm-12 form-section-title"> Information </div>
<div class="form-group col-sm-12 text-muted small">
When using Microsoft AD authentication, Portainer will delegate user authentication to the Domain Controller(s) configured below; if there is no connectivity, Portainer
will fallback to internal authentication.
</div>
</div>
<div class="col-sm-12 form-section-title"> AD configuration </div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
You can configure multiple AD Controllers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all
use the same certificates).
</p>
</div>
</div>
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left" style="display: flex; flex-wrap: wrap">
AD Controller
<button
type="button"
class="label label-default interactive vertical-center"
style="border: 0"
ng-click="$ctrl.addLDAPUrl()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'plus-circle'"></pr-icon>
Add additional server
</button>
</label>
<div class="col-sm-9 col-lg-10">
<div ng-repeat="url in $ctrl.settings.URLs track by $index" style="display: flex; margin-bottom: 10px">
<input
type="text"
data-cy="ldap-url"
class="form-control"
id="ldap_url"
ng-model="$ctrl.settings.URLs[$index]"
placeholder="e.g. 10.0.0.10:389 or myldap.domain.tld:389"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
<button
ng-if="$index > 0"
class="btn btn-sm btn-danger"
type="button"
ng-click="$ctrl.removeLDAPUrl($index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'trash-2'"></pr-icon>
</button>
</div>
</div>
</div>
<div class="form-group">
<label for="ldap_username" class="col-sm-3 control-label text-left">
Service Account
<portainer-tooltip message="'Account that will be used to search for users.'"></portainer-tooltip>
</label>
<div class="col-sm-9">
<input
type="text"
data-cy="ldap-username"
class="form-control"
id="ldap_username"
ng-model="$ctrl.settings.ReaderDN"
placeholder="reader@domain.tld"
ng-change="$ctrl.onAccountChange($ctrl.settings.ReaderDN)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
</div>
<div class="form-group">
<label for="ldap_password" class="col-sm-3 control-label text-left">
Service Account Password
<portainer-tooltip message="'If you do not enter a password, Portainer will leave the current password unchanged.'"></portainer-tooltip>
</label>
<div class="col-sm-9">
<input
type="password"
class="form-control"
id="ldap_password"
ng-model="$ctrl.settings.Password"
placeholder="password"
autocomplete="new-password"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
</div>
<ldap-connectivity-check
ng-if="!$ctrl.settings.TLSConfig.TLS && !$ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-connectivity-check>
<ldap-settings-security
title="AD Connectivity Security"
settings="$ctrl.settings"
tlsca-cert="$ctrl.tlscaCert"
upload-in-progress="$ctrl.state.uploadInProgress"
on-tlsca-cert-change="($ctrl.onTlscaCertChange)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-security>
<ldap-connectivity-check
ng-if="$ctrl.settings.TLSConfig.TLS || $ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-connectivity-check>
<ldap-user-search
style="margin-top: 5px"
show-username-format="true"
settings="$ctrl.settings.SearchSettings"
domain-suffix="{{ $ctrl.domainSuffix }}"
base-filter="(objectClass=user)"
on-search-click="($ctrl.searchUsers)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-user-search>
<ldap-group-search
style="margin-top: 5px"
settings="$ctrl.settings.GroupSearchSettings"
domain-suffix="{{ $ctrl.domainSuffix }}"
base-filter="(objectClass=group)"
on-search-click="($ctrl.searchGroups)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-group-search>
<ldap-custom-admin-group
style="margin-top: 5px"
settings="$ctrl.settings"
on-search-click="($ctrl.onSearchAdminGroupsClick)"
selected-admin-groups="$ctrl.selectedAdminGroups"
default-admin-group-search-filter="'(objectClass=groupOfNames)'"
limited-feature-id="$ctrl.limitedFeatureId"
is-limited-feature-self-contained="false"
></ldap-custom-admin-group>
<ldap-settings-test-login settings="$ctrl.settings" limited-feature-id="$ctrl.limitedFeatureId" is-limited-feature-self-contained="false"></ldap-settings-test-login>
<save-auth-settings-button
on-save-settings="($ctrl.onSaveSettings)"
save-button-state="($ctrl.saveButtonState)"
limited-feature-id="$ctrl.limitedFeatureId"
save-button-disabled="($ctrl.isSaveSettingButtonDisabled())"
></save-auth-settings-button>
</div>
</div>
</ng-form>

View File

@@ -0,0 +1,15 @@
import controller from './ad-settings.controller';
export const adSettings = {
templateUrl: './ad-settings.html',
controller,
bindings: {
settings: '=',
tlscaCert: '=',
state: '=',
connectivityCheck: '<',
onSaveSettings: '<',
saveButtonState: '<',
isLdapFormValid: '&?',
},
};

View File

@@ -1,7 +1,9 @@
import angular from 'angular';
import { adSettings } from './ad-settings';
import { ldapSettings } from './ldap-settings';
import { ldapSettingsCustom } from './ldap-settings-custom';
import { ldapSettingsOpenLdap } from './ldap-settings-openldap';
import { ldapConnectivityCheck } from './ldap-connectivity-check';
import { ldapGroupSearch } from './ldap-group-search';
@@ -20,11 +22,13 @@ export default angular
.service('LDAP', LDAP)
.component('ldapConnectivityCheck', ldapConnectivityCheck)
.component('ldapSettings', ldapSettings)
.component('adSettings', adSettings)
.component('ldapGroupSearch', ldapGroupSearch)
.component('ldapGroupSearchItem', ldapGroupSearchItem)
.component('ldapUserSearch', ldapUserSearch)
.component('ldapUserSearchItem', ldapUserSearchItem)
.component('ldapSettingsCustom', ldapSettingsCustom)
.component('ldapCustomGroupSearch', ldapCustomGroupSearch)
.component('ldapSettingsOpenLdap', ldapSettingsOpenLdap)
.component('ldapSettingsSecurity', ldapSettingsSecurity)
.component('ldapCustomUserSearch', ldapCustomUserSearch).name;

View File

@@ -4,5 +4,6 @@ export const ldapConnectivityCheck = {
settings: '<',
state: '<',
connectivityCheck: '<',
limitedFeatureId: '<',
},
};

View File

@@ -11,6 +11,8 @@
ng-disabled="($ctrl.state.connectivityCheckInProgress) || (!$ctrl.settings.URLs.length) || ((!$ctrl.settings.ReaderDN || !$ctrl.settings.Password) && !$ctrl.settings.AnonymousMode)"
ng-click="$ctrl.connectivityCheck()"
button-spinner="$ctrl.state.connectivityCheckInProgress"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<span ng-hide="$ctrl.state.connectivityCheckInProgress">Test connectivity</span>
<span ng-show="$ctrl.state.connectivityCheckInProgress">Testing connectivity...</span>

View File

@@ -6,5 +6,6 @@ export const ldapCustomGroupSearch = {
bindings: {
settings: '=',
onSearchClick: '<',
limitedFeatureId: '<',
},
};

View File

@@ -50,6 +50,17 @@
</button>
</div>
</div>
<div class="form-group">
<span class="col-sm-12 small" style="color: #ffa719">
<pr-icon icon="'briefcase'" class-name="'icon icon-xs vertical-center'"></pr-icon>
Users removal synchronize between groups and teams only available in
<a href="https://www.portainer.io/features?from=custom-login-banner" target="_blank">business edition.</a>
<portainer-tooltip
class="text-muted align-bottom"
message="'Groups allows users to automatically be added to Portainer teams. However, automatically removing users from teams to keep it fully in sync is available in the Business Edition.'"
></portainer-tooltip>
</span>
</div>
</rd-widget-body>
</rd-widget>
@@ -60,6 +71,15 @@
Add group search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px">
<be-teaser-button
feature-id="$ctrl.limitedFeatureId"
heading="'Display User/Group matching'"
message="'Show the list of users and groups that match the Portainer search configurations.'"
button-text="'Display User/Group matching'"
button-class-name="'!ml-0'"
></be-teaser-button>
</div>
</div>
<ldap-groups-datatable ng-if="$ctrl.showTable" dataset="$ctrl.groups"></ldap-groups-datatable>

View File

@@ -6,5 +6,6 @@ export const ldapCustomUserSearch = {
bindings: {
settings: '=',
onSearchClick: '<',
limitedFeatureId: '<',
},
};

View File

@@ -53,6 +53,15 @@
Add user search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px">
<be-teaser-button
feature-id="$ctrl.limitedFeatureId"
heading="'Display Users'"
message="'Allows you to display users from your LDAP server.'"
button-text="'Display Users'"
button-class-name="'!ml-0'"
></be-teaser-button>
</div>
</div>
<ldap-users-datatable ng-if="$ctrl.showTable" dataset="$ctrl.users"></ldap-users-datatable>

View File

@@ -10,5 +10,6 @@ export const ldapGroupSearchItem = {
baseFilter: '@',
onRemoveClick: '<',
limitedFeatureId: '<',
},
};

View File

@@ -5,6 +5,8 @@
class="btn btn-sm btn-danger"
type="button"
ng-click="$ctrl.onRemoveClick($ctrl.index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'trash-2'"></pr-icon>
</button>
@@ -15,6 +17,7 @@
suffix="$ctrl.domainSuffix"
value="$ctrl.config.GroupBaseDN"
on-change="($ctrl.onChangeBaseDN)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-dn-builder>
<div class="form-group">
@@ -39,6 +42,8 @@
data-cy="ldap-group-search-item-select"
ng-model="entry.type"
ng-change="$ctrl.onGroupsChange()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<option value="ou">OU Name</option>
<option value="cn">Folder Name</option>
@@ -49,6 +54,8 @@
class="form-control"
ng-model="entry.value"
ng-change="$ctrl.onGroupsChange()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
<div class="col-sm-3 text-right">
@@ -56,6 +63,8 @@
class="btn btn-md btn-danger"
type="button"
ng-click="$ctrl.removeGroup($index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>

View File

@@ -9,5 +9,6 @@ export const ldapGroupSearch = {
baseFilter: '@',
onSearchClick: '<',
limitedFeatureId: '<',
},
};

View File

@@ -8,18 +8,19 @@
index="$index"
base-filter="{{ $ctrl.baseFilter }}"
on-remove-click="($ctrl.onRemoveClick)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-group-search-item>
</div>
<div class="form-group" style="margin-top: 10px">
<div class="col-sm-12">
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-tabindex="-1">
<pr-icon icon="'plus'"></pr-icon>
Add group search configuration
</button>
</div>
<div class="col-sm-12" style="margin-top: 10px">
<button class="btn btm-sm btn-primary !ml-0" type="button" ng-click="$ctrl.search()">
<button class="btn btm-sm btn-primary !ml-0" type="button" ng-click="$ctrl.search()" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-tabindex="-1">
Display User/Group matching
</button>
</div>

View File

@@ -1,7 +1,10 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
export default class LdapSettingsCustomController {
/* @ngInject */
constructor($scope) {
this.$scope = $scope;
this.limitedFeatureId = FeatureId.EXTERNAL_AUTH_LDAP;
this.onAdminGroupSearchSettingsChange = this.onAdminGroupSearchSettingsChange.bind(this);
this.onAutoPopulateChange = this.onAutoPopulateChange.bind(this);

View File

@@ -35,6 +35,15 @@
</button>
</div>
</div>
<div class="col-sm-12">
<be-teaser-button
feature-id="$ctrl.limitedFeatureId"
heading="'Add additional server'"
message="'Allows you to add an additional LDAP server.'"
button-text="'Add additional server'"
button-class-name="'!ml-0'"
></be-teaser-button>
</div>
</div>
<div class="form-group">
@@ -48,6 +57,8 @@
type="checkbox"
id="anonymous_mode"
ng-model="$ctrl.settings.AnonymousMode"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
data-cy="anonymous-mode-checkbox"
/>
<span class="slider round"></span>
@@ -104,12 +115,14 @@
class="block"
settings="$ctrl.settings.SearchSettings"
on-search-click="($ctrl.onSearchUsersClick)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-custom-user-search>
<ldap-custom-group-search
class="block"
settings="$ctrl.settings.GroupSearchSettings"
on-search-click="($ctrl.onSearchGroupsClick)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-custom-group-search>
<ldap-custom-admin-group
@@ -121,13 +134,22 @@
on-auto-populate-change="($ctrl.onAutoPopulateChange)"
selected-admin-groups="$ctrl.selectedAdminGroups"
on-selected-admin-groups-change="($ctrl.onSelectedAdminGroupsChange)"
limited-feature-id="$ctrl.limitedFeatureId"
is-limited-feature-self-contained="true"
></ldap-custom-admin-group>
<ldap-settings-test-login class="block" settings="$ctrl.settings"></ldap-settings-test-login>
<ldap-settings-test-login
class="block"
settings="$ctrl.settings"
limited-feature-id="$ctrl.limitedFeatureId"
show-be-indicator-if-needed="true"
is-limited-feature-self-contained="true"
></ldap-settings-test-login>
</div>
<save-auth-settings-button
on-save-settings="($ctrl.onSaveSettings)"
save-button-state="($ctrl.saveButtonState)"
save-button-disabled="!$ctrl.saveButtonDisabled()"
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
></save-auth-settings-button>

View File

@@ -0,0 +1,18 @@
import controller from './ldap-settings-openldap.controller';
export const ldapSettingsOpenLdap = {
templateUrl: './ldap-settings-openldap.html',
controller,
bindings: {
settings: '=',
tlscaCert: '=',
state: '=',
connectivityCheck: '<',
onTlscaCertChange: '<',
onSearchUsersClick: '<',
onSearchGroupsClick: '<',
onSaveSettings: '<',
saveButtonState: '<',
saveButtonDisabled: '<',
},
};

View File

@@ -0,0 +1,45 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
export default class LdapSettingsOpenLDAPController {
/* @ngInject */
constructor() {
this.domainSuffix = '';
this.limitedFeatureId = FeatureId.EXTERNAL_AUTH_LDAP;
this.findDomainSuffix = this.findDomainSuffix.bind(this);
this.parseDomainSuffix = this.parseDomainSuffix.bind(this);
this.onAccountChange = this.onAccountChange.bind(this);
}
findDomainSuffix() {
const serviceAccount = this.settings.ReaderDN;
let domainSuffix = this.parseDomainSuffix(serviceAccount);
if (!domainSuffix && this.settings.SearchSettings.length > 0) {
const searchSettings = this.settings.SearchSettings[0];
domainSuffix = this.parseDomainSuffix(searchSettings.BaseDN);
}
this.domainSuffix = domainSuffix;
}
parseDomainSuffix(string = '') {
const index = string.toLowerCase().indexOf('dc=');
return index !== -1 ? string.substring(index) : '';
}
onAccountChange(serviceAccount) {
this.domainSuffix = this.parseDomainSuffix(serviceAccount);
}
addLDAPUrl() {
this.settings.URLs.push('');
}
removeLDAPUrl(index) {
this.settings.URLs.splice(index, 1);
}
$onInit() {
this.findDomainSuffix();
}
}

View File

@@ -0,0 +1,199 @@
<ng-form limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-class="limited-be" class="ldap-settings-openldap">
<div class="be-indicator-container">
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator></div>
<div class="limited-be-content">
<div>
<div class="col-sm-12 form-section-title"> Information </div>
<div class="form-group col-sm-12 text-muted small">
When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails.
</div>
</div>
<div class="col-sm-12 form-section-title"> LDAP configuration </div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
You can configure multiple LDAP Servers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all use
the same certificates).
</p>
</div>
</div>
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left" style="display: flex; flex-wrap: wrap">
LDAP Server
<button
type="button"
class="label label-default interactive vertical-center"
style="border: 0"
ng-click="$ctrl.addLDAPUrl()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'plus-circle'"></pr-icon>
Add additional server
</button>
</label>
<div class="col-sm-9 col-lg-10">
<div ng-repeat="url in $ctrl.settings.URLs track by $index" style="display: flex; margin-bottom: 10px">
<input
type="text"
data-cy="ldap-url"
class="form-control"
id="ldap_url"
ng-model="$ctrl.settings.URLs[$index]"
placeholder="e.g. 10.0.0.10:389 or myldap.domain.tld:389"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
<button
ng-if="$index > 0"
class="btn btn-sm btn-danger"
type="button"
ng-click="$ctrl.removeLDAPUrl($index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>
</div>
</div>
</div>
<!-- Anonymous mode-->
<div class="form-group">
<div class="col-sm-12">
<label for="anonymous_mode" class="control-label col-sm-3 col-lg-2 text-left">
Anonymous mode
<portainer-tooltip message="'Enable this option if the server is configured for Anonymous access.'"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<label class="switch">
<input
type="checkbox"
id="anonymous_mode"
ng-model="$ctrl.settings.AnonymousMode"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
data-cy="ldap-anonymous-mode"
/>
<span class="slider round"></span>
</label>
</div>
</div>
</div>
<!-- !Anonymous mode-->
<div ng-if="!$ctrl.settings.AnonymousMode">
<div class="form-group">
<label for="ldap_username" class="col-sm-3 col-lg-2 control-label text-left">
Reader DN
<portainer-tooltip message="'Account that will be used to search for users.'"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="ldap-reader-dn"
class="form-control"
id="ldap_username"
ng-model="$ctrl.settings.ReaderDN"
placeholder="cn=user,dc=domain,dc=tld"
ng-change="$ctrl.onAccountChange($ctrl.settings.ReaderDN)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
</div>
<div class="form-group">
<label for="ldap_password" class="col-sm-3 col-lg-2 control-label text-left">
Password
<portainer-tooltip message="'If you do not enter a password, Portainer will leave the current password unchanged.'"></portainer-tooltip>
</label>
<div class="col-sm-9">
<input
type="password"
class="form-control"
id="ldap_password"
ng-model="$ctrl.settings.Password"
placeholder="password"
autocomplete="new-password"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
</div>
</div>
<div class="form-group" ng-if="$ctrl.settings.AnonymousMode">
<label for="ldap_domain_root" class="col-sm-3 col-lg-2 control-label text-left"> Domain root </label>
<div class="col-sm-9">
<input
type="text"
data-cy="ldap-domain-root"
class="form-control"
id="ldap_domain_root"
ng-model="$ctrl.domainSuffix"
placeholder="dc=domain,dc=tld"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
</div>
<ldap-connectivity-check
ng-if="!$ctrl.settings.TLSConfig.TLS && !$ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-connectivity-check>
<ldap-settings-security
title="Connectivity Security"
settings="$ctrl.settings"
tlsca-cert="$ctrl.tlscaCert"
upload-in-progress="$ctrl.state.uploadInProgress"
on-tlsca-cert-change="($ctrl.onTlscaCertChange)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-security>
<ldap-connectivity-check
ng-if="$ctrl.settings.TLSConfig.TLS || $ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-connectivity-check>
<ldap-user-search
style="margin-top: 5px"
settings="$ctrl.settings.SearchSettings"
domain-suffix="{{ $ctrl.domainSuffix }}"
base-filter="(objectClass=inetOrgPerson)"
on-search-click="($ctrl.onSearchUsersClick)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-user-search>
<ldap-group-search
style="margin-top: 5px"
settings="$ctrl.settings.GroupSearchSettings"
domain-suffix="{{ $ctrl.domainSuffix }}"
base-filter="(objectClass=groupOfNames)"
on-search-click="($ctrl.onSearchGroupsClick)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-group-search>
<ldap-settings-test-login settings="$ctrl.settings" limited-feature-id="$ctrl.limitedFeatureId"></ldap-settings-test-login>
<save-auth-settings-button
on-save-settings="($ctrl.onSaveSettings)"
save-button-state="($ctrl.saveButtonState)"
save-button-disabled="!$ctrl.saveButtonDisabled()"
limited-feature-id="$ctrl.limitedFeatureId"
></save-auth-settings-button>
</div>
</div>
</ng-form>

View File

@@ -6,6 +6,7 @@ export const ldapSettingsSecurity = {
onTlscaCertChange: '<',
uploadInProgress: '<',
title: '@',
limitedFeatureId: '<',
},
controller: LdapController,
};

View File

@@ -3,4 +3,5 @@
on-change="($ctrl.onChangeReactValues)"
upload-state="$ctrl.getUploadState()"
title="$ctrl.title"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-security-fieldset>

View File

@@ -46,3 +46,16 @@ export function buildAdSettingsModel() {
return settings;
}
export function buildOpenLDAPSettingsModel() {
const settings = buildLdapSettingsModel();
settings.ServerType = 1;
settings.AnonymousMode = false;
settings.SearchSettings[0].UserNameAttribute = 'uid';
settings.SearchSettings[0].Filter = '(objectClass=inetOrgPerson)';
settings.GroupSearchSettings[0].GroupAttribute = 'member';
settings.GroupSearchSettings[0].GroupFilter = '(objectClass=groupOfNames)';
return settings;
}

View File

@@ -1,16 +1,30 @@
import { buildLdapSettingsModel, buildOpenLDAPSettingsModel } from '@/portainer/settings/authentication/ldap/ldap-settings.model';
import { options } from '@/react/portainer/settings/AuthenticationView/ldap-options';
const SERVER_TYPES = {
CUSTOM: 0,
OPEN_LDAP: 1,
AD: 2,
};
const DEFAULT_GROUP_FILTER = '(objectClass=groupOfNames)';
const DEFAULT_USER_FILTER = '(objectClass=inetOrgPerson)';
export default class LdapSettingsController {
/* @ngInject */
constructor(LDAPService, $scope) {
Object.assign(this, { LDAPService, $scope });
Object.assign(this, { LDAPService, SERVER_TYPES, $scope });
this.tlscaCert = null;
this.settingsDrafts = {};
this.boxSelectorOptions = options;
this.onTlscaCertChange = this.onTlscaCertChange.bind(this);
this.searchUsers = this.searchUsers.bind(this);
this.searchGroups = this.searchGroups.bind(this);
this.onChangeServerType = this.onChangeServerType.bind(this);
this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this);
this.onAutoUserProvisionChange = this.onAutoUserProvisionChange.bind(this);
}
@@ -27,6 +41,24 @@ export default class LdapSettingsController {
this.tlscaCert = this.settings.TLSConfig.TLSCACert;
}
onChangeServerType(serverType) {
this.settingsDrafts[this.settings.ServerType] = this.settings;
if (this.settingsDrafts[serverType]) {
this.settings = this.settingsDrafts[serverType];
return;
}
switch (serverType) {
case SERVER_TYPES.OPEN_LDAP:
this.settings = buildOpenLDAPSettingsModel();
break;
case SERVER_TYPES.CUSTOM:
this.settings = buildLdapSettingsModel();
break;
}
}
searchUsers() {
const settings = {
...this.settings,

View File

@@ -5,7 +5,18 @@
description="'With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s). If disabled, users must be created in Portainer beforehand.'"
></auto-user-provision-toggle>
<box-selector
style="margin-bottom: 0"
radio-name="'ldap-server-type-selector'"
value="$ctrl.settings.ServerType"
options="$ctrl.boxSelectorOptions"
on-change="($ctrl.onChangeServerType)"
slim="true"
label="'Server Type'"
></box-selector>
<ldap-settings-custom
ng-if="$ctrl.settings.ServerType === $ctrl.SERVER_TYPES.CUSTOM"
settings="$ctrl.settings"
tlsca-cert="$ctrl.tlscaCert"
state="$ctrl.state"
@@ -17,4 +28,17 @@
save-button-state="($ctrl.saveButtonState)"
save-button-disabled="$ctrl.isLdapFormValid"
></ldap-settings-custom>
<ldap-settings-open-ldap
ng-if="$ctrl.settings.ServerType === $ctrl.SERVER_TYPES.OPEN_LDAP"
settings="$ctrl.settings"
tlsca-cert="$ctrl.tlscaCert"
state="$ctrl.state"
on-tlsca-cert-change="($ctrl.onTlscaCertChange)"
connectivity-check="$ctrl.connectivityCheck"
on-search-users-click="($ctrl.searchUsers)"
on-search-groups-click="($ctrl.searchGroups)"
on-save-settings="($ctrl.onSaveSettings)"
save-button-state="($ctrl.saveButtonState)"
save-button-disabled="$ctrl.isLdapFormValid"
></ldap-settings-open-ldap>
</div>

View File

@@ -10,5 +10,6 @@ export const ldapUserSearchItem = {
domainSuffix: '@',
baseFilter: '@',
onRemoveClick: '<',
limitedFeatureId: '<',
},
};

Some files were not shown because too many files have changed in this diff Show More