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>
168 lines
4.9 KiB
Go
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{}
|