Files
moby/daemon/extensions_containerstate.go
Paweł Gronowski dda2877a6c Add an extension point for watching container state
Jobs extensions need to know when a container finishes so they can
record the outcome of a run.

Add org.mobyproject.extension.container.state.v0.
Its Wait call returns once the container reaches a requested status,
and Watch streams every status change until the container is removed.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2026-10-28 15:56:21 +01:00

168 lines
4.9 KiB
Go

// SPDX-FileCopyrightText: Copyright The Moby Authors
// SPDX-License-Identifier: Apache-2.0
package daemon
import (
"context"
"slices"
"github.com/containerd/errdefs/pkg/errgrpc"
"github.com/moby/extensions/stream"
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/v2/daemon/container"
"github.com/moby/moby/v2/errdefs"
containerstatev0 "github.com/moby/moby/v2/extpoints/containerstate/v0"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// grpcError keeps error classification for in-process callers while also
// carrying a gRPC status for the callback server.
type grpcError struct{ error }
func (e grpcError) Unwrap() error { return e.error }
func (e grpcError) GRPCStatus() *status.Status {
return status.Convert(errgrpc.ToGRPC(e.error))
}
type containerStateWatcher struct {
daemon *Daemon
}
func (w containerStateWatcher) Wait(ctx context.Context, req *containerstatev0.WaitRequest) (*containerstatev0.WaitReply, error) {
if req == nil || req.ContainerID == "" {
return nil, errdefs.InvalidParameter(status.Error(codes.InvalidArgument, "container ID is required"))
}
if len(req.Statuses) > 0 && len(req.NotStatuses) > 0 {
return nil, errdefs.InvalidParameter(status.Error(codes.InvalidArgument, "only one of Statuses and NotStatuses can be set"))
}
for _, s := range slices.Concat(req.Statuses, req.NotStatuses) {
if s != containerstatev0.StatusRemoved && containertypes.ValidateContainerState(containertypes.ContainerState(s)) != nil {
return nil, errdefs.InvalidParameter(status.Errorf(codes.InvalidArgument, "invalid status: %q", s))
}
}
ctr, err := w.lookup(req.ContainerID)
if err != nil {
return nil, err
}
for r, err := range ctr.State.StatusChanges(ctx) {
if err != nil {
return nil, statusChangesError(ctx, err)
}
reply := waitReply(r)
cur := reply.Current.Status
matched := slices.Contains(req.Statuses, cur)
if len(req.Statuses) == 0 {
matched = !slices.Contains(req.NotStatuses, cur)
}
// A removed container's state never changes again.
if matched || r.Removed {
return reply, nil
}
}
// StatusChanges only stops after yielding a removal or an error.
return nil, errStatusChangesEnded
}
func (w containerStateWatcher) Watch(ctx context.Context, req *containerstatev0.WatchRequest, events stream.Sender[containerstatev0.WatchEvent]) error {
if req == nil || req.ContainerID == "" {
return errdefs.InvalidParameter(status.Error(codes.InvalidArgument, "container ID is required"))
}
ctr, err := w.lookup(req.ContainerID)
if err != nil {
return err
}
for r, err := range ctr.State.StatusChanges(ctx) {
if err != nil {
return statusChangesError(ctx, err)
}
reply := waitReply(r)
if err := events.Send(&containerstatev0.WatchEvent{
ChangedAt: reply.ChangedAt,
Current: reply.Current,
Previous: reply.Previous,
}); err != nil {
return err
}
if r.Removed {
return nil
}
}
return errStatusChangesEnded
}
// errStatusChangesEnded is returned if State.StatusChanges stops without
// yielding a removal or an error, which it does not do.
var errStatusChangesEnded = errdefs.Unavailable(status.Error(codes.Unavailable, "container status changes ended"))
// lookup returns the container with the given ID, name, or unique ID prefix
// once daemon startup is complete.
func (w containerStateWatcher) lookup(id string) (*container.Container, error) {
select {
case <-w.daemon.startupDone:
default:
return nil, errdefs.Unavailable(status.Error(codes.Unavailable, "daemon startup is not complete"))
}
ctr, err := w.daemon.GetContainer(id)
if err != nil {
return nil, grpcError{err}
}
return ctr, nil
}
// statusChangesError returns the error to report for an error yielded by
// State.StatusChanges.
func statusChangesError(ctx context.Context, err error) error {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
return grpcError{err}
}
// waitReply returns the point's view of r.
func waitReply(r container.StatusResult) *containerstatev0.WaitReply {
reply := &containerstatev0.WaitReply{Current: containerState(r.Current)}
if r.Removed {
reply.Current.Status = containerstatev0.StatusRemoved
}
if !r.ChangedAt.IsZero() {
reply.ChangedAt = r.ChangedAt.UnixNano()
reply.Previous = containerState(r.Previous)
}
return reply
}
// containerState returns the point's view of s.
func containerState(s container.StateSnapshot) *containerstatev0.ContainerState {
cs := &containerstatev0.ContainerState{Status: string(s.Status)}
// Only running and paused containers have not exited since they started;
// a restarting container reports the exit that triggered the restart.
running := s.Status == containertypes.StateRunning || s.Status == containertypes.StatePaused
if !running && !s.FinishedAt.IsZero() {
cs.Exit = &containerstatev0.Exit{
ExitCode: int64(s.ExitCode),
Error: s.Error,
OOMKilled: s.OOMKilled,
}
}
return cs
}
var _ containerstatev0.ContainerStateWatcher = containerStateWatcher{}