mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
* Add server side of journal logs This add the server side and grpc methods to get a journal logs from a machine. It repurposes ServiceLogEntry for these logs to keep the changes somewhat to a minimum. And it lets us re-use the merging of the various logs. In the protobufs ContainerLog has been renamed to just Log and LogEntry, as these are now also used for journal logs. It does api.LogOptions in more places to reduce the various logOpts that were used. It does not yet plumb it through to the uc client, that needs a follow up pr. Following logs is also not yet implemented. Signed-off-by: Miek Gieben <miek@miek.nl> * Fix test too Signed-off-by: Miek Gieben <miek@miek.nl> * remove entire comment Signed-off-by: Miek Gieben <miek@miek.nl> * Implement the follow option, untested mind you Signed-off-by: Miek Gieben <miek@miek.nl> * update debug line Signed-off-by: Miek Gieben <miek@miek.nl> * internal/jounal: First batch of PR comments Signed-off-by: Miek Gieben <miek@miek.nl> * internal/journal: code review comments Signed-off-by: Miek Gieben <miek@miek.nl> * Manually apply suggestion Signed-off-by: Miek Gieben <miek@miek.nl> * apply comment manually Signed-off-by: Miek Gieben <miek@miek.nl> * internal/journal: add unit test Signed-off-by: Miek Gieben <miek@miek.nl> * Use testify Signed-off-by: Miek Gieben <miek@miek.nl> * -amFix scanner.Err checking Signed-off-by: Miek Gieben <miek@miek.nl> * Implement code review comments Signed-off-by: Miek Gieben <miek@miek.nl> --------- Signed-off-by: Miek Gieben <miek@miek.nl>
1420 lines
45 KiB
Go
1420 lines
45 KiB
Go
package docker
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/containerd/errdefs"
|
|
"github.com/distribution/reference"
|
|
dockercommand "github.com/docker/cli/cli/command"
|
|
dockerconfig "github.com/docker/cli/cli/config"
|
|
"github.com/docker/docker/api/types"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/api/types/filters"
|
|
"github.com/docker/docker/api/types/image"
|
|
"github.com/docker/docker/api/types/mount"
|
|
"github.com/docker/docker/api/types/network"
|
|
"github.com/docker/docker/api/types/volume"
|
|
"github.com/docker/docker/client"
|
|
"github.com/docker/docker/pkg/stdcopy"
|
|
"github.com/docker/go-connections/nat"
|
|
"github.com/docker/go-units"
|
|
"github.com/google/go-containerregistry/pkg/authn"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/opencontainers/go-digest"
|
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
|
"github.com/psviderski/uncloud/internal/machine/dns"
|
|
"github.com/psviderski/uncloud/internal/secret"
|
|
"github.com/psviderski/uncloud/pkg/api"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/emptypb"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
)
|
|
|
|
var fullDockerIDRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
|
|
|
// Server implements the gRPC Docker service that proxies requests to the Docker daemon.
|
|
type Server struct {
|
|
pb.UnimplementedDockerServer
|
|
client *client.Client
|
|
service *Service
|
|
db *sqlx.DB
|
|
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
|
// address if the address is unknown (e.g. when the machine is not initialised yet).
|
|
internalDNSIP func() netip.Addr
|
|
// machineID is a function that returns the machine ID. It may return an empty string if the machine
|
|
// is not initialised yet.
|
|
machineID func() string
|
|
// networkReady is a function that returns true if the Docker network is ready for containers.
|
|
networkReady func() bool
|
|
// waitForNetworkReady is a function that waits for the Docker network to be ready for containers.
|
|
waitForNetworkReady func(ctx context.Context) error
|
|
}
|
|
|
|
type ServerOptions struct {
|
|
// TODO: verify if we still need the network readiness checks as the cluster controller ensures the network
|
|
// is ready before starting the network API server. It may still be needed when communicating with the local
|
|
// API server but in this case we should probably fail until the cluster is initialised.
|
|
NetworkReady func() bool
|
|
WaitForNetworkReady func(ctx context.Context) error
|
|
}
|
|
|
|
// NewServer creates a new Docker gRPC server with the provided Docker service.
|
|
func NewServer(service *Service, db *sqlx.DB, internalDNSIP func() netip.Addr, machineID func() string, opts ServerOptions) *Server {
|
|
s := &Server{
|
|
client: service.Client,
|
|
service: service,
|
|
db: db,
|
|
internalDNSIP: internalDNSIP,
|
|
machineID: machineID,
|
|
}
|
|
|
|
s.networkReady = opts.NetworkReady
|
|
s.waitForNetworkReady = opts.WaitForNetworkReady
|
|
|
|
return s
|
|
}
|
|
|
|
// CreateContainer creates a new container based on the given configuration.
|
|
func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerRequest) (*pb.CreateContainerResponse, error) {
|
|
var config container.Config
|
|
var hostConfig container.HostConfig
|
|
var networkConfig network.NetworkingConfig
|
|
var platform ocispec.Platform
|
|
|
|
// Unmarshal configurations from the request.
|
|
if err := json.Unmarshal(req.Config, &config); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal container config: %v", err)
|
|
}
|
|
if err := json.Unmarshal(req.HostConfig, &hostConfig); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal host config: %v", err)
|
|
}
|
|
if err := json.Unmarshal(req.NetworkConfig, &networkConfig); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal network config: %v", err)
|
|
}
|
|
if err := json.Unmarshal(req.Platform, &platform); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal platform: %v", err)
|
|
}
|
|
|
|
resp, err := s.client.ContainerCreate(ctx, &config, &hostConfig, &networkConfig, &platform, req.Name)
|
|
if err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
respBytes, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
|
}
|
|
|
|
return &pb.CreateContainerResponse{Response: respBytes}, nil
|
|
}
|
|
|
|
// InspectContainer returns the container information for the given container ID.
|
|
func (s *Server) InspectContainer(ctx context.Context, req *pb.InspectContainerRequest) (*pb.InspectContainerResponse, error) {
|
|
resp, err := s.client.ContainerInspect(ctx, req.Id)
|
|
if err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
respBytes, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
|
}
|
|
|
|
return &pb.InspectContainerResponse{Response: respBytes}, nil
|
|
}
|
|
|
|
// StartContainer starts a container with the given ID and options.
|
|
func (s *Server) StartContainer(ctx context.Context, req *pb.StartContainerRequest) (*emptypb.Empty, error) {
|
|
// Wait for Docker network to be ready before starting the container
|
|
if s.waitForNetworkReady != nil {
|
|
if err := s.waitForNetworkReady(ctx); err != nil {
|
|
return nil, status.Errorf(codes.Unavailable, "Docker network not ready: %v", err)
|
|
}
|
|
} else if s.networkReady != nil && !s.networkReady() {
|
|
return nil, status.Errorf(codes.Unavailable, "Docker network not ready")
|
|
}
|
|
|
|
var opts container.StartOptions
|
|
if len(req.Options) > 0 {
|
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := s.client.ContainerStart(ctx, req.Id, opts); err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// StopContainer stops a container with the given ID and options.
|
|
func (s *Server) StopContainer(ctx context.Context, req *pb.StopContainerRequest) (*emptypb.Empty, error) {
|
|
var opts container.StopOptions
|
|
if len(req.Options) > 0 {
|
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := s.client.ContainerStop(ctx, req.Id, opts); err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
func (s *Server) ListContainers(ctx context.Context, req *pb.ListContainersRequest) (*pb.ListContainersResponse, error) {
|
|
var opts container.ListOptions
|
|
if len(req.Options) > 0 {
|
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
|
}
|
|
|
|
// Handle filters separately because they implement custom JSON unmarshalling.
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(req.Options, &raw); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
|
|
}
|
|
|
|
if filtersBytes, ok := raw["Filters"]; ok {
|
|
args, err := filters.FromJSON(string(filtersBytes))
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
|
}
|
|
opts.Filters = args
|
|
}
|
|
}
|
|
|
|
containerSummaries, err := s.client.ContainerList(ctx, opts)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
containers := make([]container.InspectResponse, 0, len(containerSummaries))
|
|
for _, cs := range containerSummaries {
|
|
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
|
if err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
// The listed container may have been removed while we were inspecting other containers.
|
|
continue
|
|
}
|
|
return nil, status.Errorf(codes.Internal, "inspect container %s: %v", cs.ID, err)
|
|
}
|
|
containers = append(containers, c)
|
|
}
|
|
|
|
containersBytes, err := json.Marshal(containers)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal containers: %v", err)
|
|
}
|
|
|
|
return &pb.ListContainersResponse{
|
|
Messages: []*pb.MachineContainers{
|
|
{
|
|
Containers: containersBytes,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// RemoveContainer stops (kills after grace period) and removes a container with the given ID.
|
|
func (s *Server) RemoveContainer(ctx context.Context, req *pb.RemoveContainerRequest) (*emptypb.Empty, error) {
|
|
var opts container.RemoveOptions
|
|
if len(req.Options) > 0 {
|
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := s.client.ContainerRemove(ctx, req.Id, opts); err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreamingServer[pb.JSONMessage]) error {
|
|
ctx := stream.Context()
|
|
|
|
var opts image.PullOptions
|
|
if len(req.Options) > 0 {
|
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
|
return status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
|
}
|
|
}
|
|
|
|
if opts.RegistryAuth == "" {
|
|
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
|
dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr)
|
|
if encodedAuth, err := dockercommand.RetrieveAuthTokenFromImage(dockerConfig, req.Image); err == nil {
|
|
opts.RegistryAuth = encodedAuth
|
|
}
|
|
}
|
|
|
|
respBody, err := s.client.ImagePull(ctx, req.Image, opts)
|
|
if err != nil {
|
|
return status.Error(codes.Internal, err.Error())
|
|
}
|
|
defer respBody.Close()
|
|
|
|
decoder := json.NewDecoder(respBody)
|
|
errCh := make(chan error, 1)
|
|
|
|
go func() {
|
|
var raw json.RawMessage
|
|
for {
|
|
if err = decoder.Decode(&raw); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
errCh <- nil
|
|
return
|
|
}
|
|
errCh <- status.Errorf(codes.Internal, "decode image pull message: %v", err)
|
|
return
|
|
}
|
|
|
|
if err = stream.Send(&pb.JSONMessage{Message: raw}); err != nil {
|
|
errCh <- status.Errorf(codes.Internal, "send image pull message to stream: %v", err)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case err = <-errCh:
|
|
return err
|
|
case <-ctx.Done():
|
|
return status.Error(codes.Canceled, ctx.Err().Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
// InspectImage returns the image information for the given image ID.
|
|
func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest) (*pb.InspectImageResponse, error) {
|
|
resp, err := s.client.ImageInspect(ctx, req.Id)
|
|
if err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
respBytes, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
|
}
|
|
|
|
return &pb.InspectImageResponse{
|
|
Messages: []*pb.Image{
|
|
{
|
|
Image: respBytes,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's Docker auth
|
|
// credentials if necessary.
|
|
func (s *Server) InspectRemoteImage(
|
|
_ context.Context, req *pb.InspectRemoteImageRequest,
|
|
) (*pb.InspectRemoteImageResponse, error) {
|
|
ref, err := name.ParseReference(req.Id)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "parse image: %v", err)
|
|
}
|
|
|
|
desc, err := remote.Get(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain))
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "fetch image manifest: %v", err)
|
|
}
|
|
|
|
namedRef, err := reference.ParseNormalizedNamed(ref.String())
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "parse image: %v", err)
|
|
}
|
|
|
|
var canonicalRef reference.Canonical
|
|
if _, ok := namedRef.(reference.Canonical); ok {
|
|
canonicalRef = namedRef.(reference.Canonical)
|
|
} else {
|
|
if canonicalRef, err = reference.WithDigest(namedRef, digest.Digest(desc.Digest.String())); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "add digest to image: %v", err)
|
|
}
|
|
}
|
|
|
|
return &pb.InspectRemoteImageResponse{
|
|
Messages: []*pb.RemoteImage{
|
|
{
|
|
Reference: reference.FamiliarString(canonicalRef),
|
|
Manifest: desc.Manifest,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ListImages returns a list of all images matching the filter and indicates whether Docker is using the containerd
|
|
// image store.
|
|
func (s *Server) ListImages(ctx context.Context, req *pb.ListImagesRequest) (*pb.ListImagesResponse, error) {
|
|
var opts image.ListOptions
|
|
if len(req.Options) > 0 {
|
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
|
}
|
|
|
|
// Handle filters separately because they implement custom JSON unmarshalling.
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(req.Options, &raw); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
|
|
}
|
|
|
|
if filtersBytes, ok := raw["Filters"]; ok {
|
|
args, err := filters.FromJSON(string(filtersBytes))
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
|
}
|
|
opts.Filters = args
|
|
}
|
|
}
|
|
|
|
images, err := s.service.ListImages(ctx, opts)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
machineImages := pb.MachineImages{
|
|
ContainerdStore: images.ContainerdStore,
|
|
}
|
|
if len(images.Images) > 0 {
|
|
if machineImages.Images, err = json.Marshal(images.Images); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal Docker images: %v", err)
|
|
}
|
|
}
|
|
|
|
return &pb.ListImagesResponse{
|
|
Messages: []*pb.MachineImages{&machineImages},
|
|
}, nil
|
|
}
|
|
|
|
// CreateVolume creates a new volume with the given options.
|
|
func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest) (*pb.CreateVolumeResponse, error) {
|
|
var opts volume.CreateOptions
|
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
|
}
|
|
|
|
// Always add the managed label to the volume to indicate that it is managed by Uncloud.
|
|
if opts.Labels == nil {
|
|
opts.Labels = make(map[string]string)
|
|
}
|
|
opts.Labels[api.LabelManaged] = ""
|
|
|
|
vol, err := s.client.VolumeCreate(ctx, opts)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
volBytes, err := json.Marshal(vol)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal volume: %v", err)
|
|
}
|
|
|
|
return &pb.CreateVolumeResponse{Volume: volBytes}, nil
|
|
}
|
|
|
|
// ListVolumes returns a list of all volumes matching the filter.
|
|
func (s *Server) ListVolumes(ctx context.Context, req *pb.ListVolumesRequest) (*pb.ListVolumesResponse, error) {
|
|
var opts volume.ListOptions
|
|
if len(req.Options) > 0 {
|
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
|
}
|
|
|
|
// Handle filters separately because they implement custom JSON unmarshalling.
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(req.Options, &raw); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
|
|
}
|
|
|
|
if filtersBytes, ok := raw["Filters"]; ok {
|
|
args, err := filters.FromJSON(string(filtersBytes))
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
|
}
|
|
opts.Filters = args
|
|
}
|
|
}
|
|
|
|
resp, err := s.client.VolumeList(ctx, opts)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
respBytes, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
|
}
|
|
|
|
return &pb.ListVolumesResponse{
|
|
Messages: []*pb.MachineVolumes{
|
|
{
|
|
Response: respBytes,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// RemoveVolume removes a volume with the given ID.
|
|
func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest) (*emptypb.Empty, error) {
|
|
if err := s.client.VolumeRemove(ctx, req.Id, req.Force); err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// CreateServiceContainer creates a new container for the service with the given specifications.
|
|
// TODO: move the main logic to the Docker service and remove db dependency from the server.
|
|
func (s *Server) CreateServiceContainer(
|
|
ctx context.Context, req *pb.CreateServiceContainerRequest,
|
|
) (*pb.CreateContainerResponse, error) {
|
|
if !api.ValidateServiceID(req.ServiceId) {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid service ID: '%s'", req.ServiceId)
|
|
}
|
|
|
|
var spec api.ServiceSpec
|
|
if err := json.Unmarshal(req.ServiceSpec, &spec); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal service spec: %v", err)
|
|
}
|
|
spec = spec.SetDefaults()
|
|
if err := spec.Validate(); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid service spec: %v", err)
|
|
}
|
|
|
|
containerName := req.ContainerName
|
|
if containerName == "" {
|
|
suffix, err := secret.RandomAlphaNumeric(4)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate random suffix: %w", err)
|
|
}
|
|
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
|
|
}
|
|
|
|
envVars := maps.Clone(spec.Container.Env)
|
|
if envVars == nil {
|
|
envVars = make(api.EnvVars)
|
|
}
|
|
|
|
// Inject the machine ID if available
|
|
if s.machineID != nil {
|
|
if machineID := s.machineID(); machineID != "" {
|
|
envVars["UNCLOUD_MACHINE_ID"] = machineID
|
|
}
|
|
}
|
|
|
|
config := &container.Config{
|
|
Cmd: spec.Container.Command,
|
|
Env: envVars.ToSlice(),
|
|
Entrypoint: spec.Container.Entrypoint,
|
|
Hostname: containerName,
|
|
Image: spec.Container.Image,
|
|
Labels: map[string]string{
|
|
api.LabelServiceID: req.ServiceId,
|
|
api.LabelServiceName: spec.Name,
|
|
api.LabelServiceMode: spec.Mode,
|
|
api.LabelManaged: "",
|
|
},
|
|
User: spec.Container.User,
|
|
}
|
|
if spec.Mode == "" {
|
|
config.Labels[api.LabelServiceMode] = api.ServiceModeReplicated
|
|
}
|
|
if hc := spec.Container.Healthcheck; hc != nil {
|
|
if hc.Disable {
|
|
config.Healthcheck = &container.HealthConfig{
|
|
Test: []string{"NONE"},
|
|
}
|
|
} else {
|
|
config.Healthcheck = &container.HealthConfig{
|
|
Test: hc.Test,
|
|
Interval: hc.Interval,
|
|
Timeout: hc.Timeout,
|
|
StartPeriod: hc.StartPeriod,
|
|
StartInterval: hc.StartInterval,
|
|
Retries: int(hc.Retries),
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: do not set the ports as container labels once migrated to retrieve them from the spec in DB.
|
|
var err error
|
|
if len(spec.Ports) > 0 {
|
|
encodedPorts := make([]string, len(spec.Ports))
|
|
for i, p := range spec.Ports {
|
|
encodedPorts[i], err = p.String()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode service port spec: %w", err)
|
|
}
|
|
}
|
|
|
|
config.Labels[api.LabelServicePorts] = strings.Join(encodedPorts, ",")
|
|
}
|
|
|
|
mounts, err := ToDockerMounts(spec.Volumes, spec.Container.VolumeMounts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = s.verifyDockerVolumesExist(ctx, mounts); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
portBindings := make(nat.PortMap)
|
|
for _, p := range spec.Ports {
|
|
if p.Mode != api.PortModeHost {
|
|
continue
|
|
}
|
|
port := nat.Port(fmt.Sprintf("%d/%s", p.ContainerPort, p.Protocol))
|
|
portBindings[port] = []nat.PortBinding{
|
|
{
|
|
HostPort: strconv.Itoa(int(p.PublishedPort)),
|
|
},
|
|
}
|
|
if p.HostIP.IsValid() {
|
|
portBindings[port][0].HostIP = p.HostIP.String()
|
|
}
|
|
}
|
|
hostConfig := &container.HostConfig{
|
|
CapAdd: spec.Container.CapAdd,
|
|
CapDrop: spec.Container.CapDrop,
|
|
Binds: spec.Container.Volumes,
|
|
Init: spec.Container.Init,
|
|
Mounts: mounts,
|
|
PidMode: container.PidMode(spec.Container.PidMode),
|
|
PortBindings: portBindings,
|
|
Privileged: spec.Container.Privileged,
|
|
Resources: container.Resources{
|
|
NanoCPUs: spec.Container.Resources.CPU,
|
|
Memory: spec.Container.Resources.Memory,
|
|
MemoryReservation: spec.Container.Resources.MemoryReservation,
|
|
Devices: toDockerDevices(spec.Container.Resources.Devices),
|
|
DeviceRequests: spec.Container.Resources.DeviceReservations,
|
|
Ulimits: toDockerUlimits(spec.Container.Resources.Ulimits),
|
|
},
|
|
// Restart service containers if they exit or a machine restarts unless they are explicitly stopped.
|
|
// For one-off containers and batch jobs we plan to use a different service type/mode.
|
|
RestartPolicy: container.RestartPolicy{
|
|
Name: container.RestartPolicyUnlessStopped,
|
|
},
|
|
ShmSize: spec.Container.Resources.SharedMemory,
|
|
Sysctls: spec.Container.Sysctls,
|
|
}
|
|
|
|
// Configure the container to use the internal DNS server if it's available.
|
|
dnsIP := s.internalDNSIP()
|
|
if dnsIP.IsValid() {
|
|
hostConfig.DNS = []string{dnsIP.String()}
|
|
// Optimize DNS resolution for service discovery by appending the search domain to names without a dot.
|
|
// For example, the first attempt for "my-service" will be "my-service.internal".
|
|
hostConfig.DNSOptions = []string{"ndots:1"}
|
|
hostConfig.DNSSearch = []string{dns.InternalDomain}
|
|
}
|
|
|
|
if spec.Container.LogDriver != nil {
|
|
hostConfig.LogConfig = container.LogConfig{
|
|
Type: spec.Container.LogDriver.Name,
|
|
Config: spec.Container.LogDriver.Options,
|
|
}
|
|
}
|
|
|
|
networkConfig := &network.NetworkingConfig{
|
|
EndpointsConfig: map[string]*network.EndpointSettings{
|
|
NetworkName: {},
|
|
},
|
|
}
|
|
|
|
resp, err := s.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, containerName)
|
|
if err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
// Inject configs into the created container
|
|
if err = s.injectConfigs(ctx, resp.ID, spec.Configs, spec.Container.ConfigMounts); err != nil {
|
|
// Remove the container if config injection fails
|
|
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
|
|
return nil, status.Errorf(codes.Internal, "inject configs: %v", err)
|
|
}
|
|
|
|
respBytes, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
|
}
|
|
|
|
// Store the container spec in the database or remove the container with its anonymous volumes if storing fails.
|
|
removeContainer := func() {
|
|
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
|
|
}
|
|
|
|
specBytes, err := json.Marshal(spec)
|
|
if err != nil {
|
|
removeContainer()
|
|
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
|
|
}
|
|
|
|
if _, err = s.db.ExecContext(ctx, `INSERT INTO containers (id, service_spec) VALUES ($1, $2)`,
|
|
resp.ID, string(specBytes)); err != nil {
|
|
removeContainer()
|
|
return nil, status.Errorf(codes.Internal, "store container in database: %v", err)
|
|
}
|
|
|
|
return &pb.CreateContainerResponse{Response: respBytes}, nil
|
|
}
|
|
|
|
func ToDockerMounts(volumes []api.VolumeSpec, mounts []api.VolumeMount) ([]mount.Mount, error) {
|
|
normalisedVolumes := make([]api.VolumeSpec, len(volumes))
|
|
for i, v := range volumes {
|
|
normalisedVolumes[i] = v.SetDefaults()
|
|
}
|
|
|
|
dockerMounts := make([]mount.Mount, 0, len(mounts))
|
|
for _, m := range mounts {
|
|
idx := slices.IndexFunc(normalisedVolumes, func(v api.VolumeSpec) bool {
|
|
return v.Name == m.VolumeName
|
|
})
|
|
if idx == -1 {
|
|
return nil, fmt.Errorf("volume mount references a volume that doesn't exist in the volumes spec: '%s'",
|
|
m.VolumeName)
|
|
}
|
|
|
|
vol := normalisedVolumes[idx]
|
|
if err := vol.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid volume: %w", err)
|
|
}
|
|
|
|
dm := mount.Mount{
|
|
Type: mount.Type(vol.Type),
|
|
Target: m.ContainerPath,
|
|
ReadOnly: m.ReadOnly,
|
|
}
|
|
|
|
switch vol.Type {
|
|
case api.VolumeTypeBind:
|
|
dm.Source = vol.BindOptions.HostPath
|
|
dm.BindOptions = toDockerBindOptions(vol.BindOptions)
|
|
case api.VolumeTypeVolume:
|
|
dm.Source = vol.DockerVolumeName()
|
|
dm.VolumeOptions = &mount.VolumeOptions{
|
|
NoCopy: vol.VolumeOptions.NoCopy,
|
|
Labels: vol.VolumeOptions.Labels,
|
|
Subpath: vol.VolumeOptions.SubPath,
|
|
DriverConfig: vol.VolumeOptions.Driver,
|
|
}
|
|
case api.VolumeTypeTmpfs:
|
|
dm.TmpfsOptions = vol.TmpfsOptions
|
|
default:
|
|
return nil, fmt.Errorf("unsupported volume type: '%s'", vol.Type)
|
|
}
|
|
|
|
dockerMounts = append(dockerMounts, dm)
|
|
}
|
|
|
|
return dockerMounts, nil
|
|
}
|
|
|
|
// injectConfigs writes config content directly into the container.
|
|
// It processes ConfigSpecs and ConfigMounts to mount configuration content into the container filesystem.
|
|
func (s *Server) injectConfigs(ctx context.Context, containerID string, configs []api.ConfigSpec, mounts []api.ConfigMount) error {
|
|
if len(configs) == 0 || len(mounts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if err := api.ValidateConfigsAndMounts(configs, mounts); err != nil {
|
|
return fmt.Errorf("validate configs and mounts: %w", err)
|
|
}
|
|
|
|
// Create a map of config name to config spec for quick lookup
|
|
configMap := make(map[string]api.ConfigSpec)
|
|
for _, config := range configs {
|
|
configMap[config.Name] = config
|
|
}
|
|
|
|
// Process each config mount
|
|
for _, m := range mounts {
|
|
config, exists := configMap[m.ConfigName]
|
|
if !exists {
|
|
return fmt.Errorf("config mount references a config that doesn't exist: '%s'", m.ConfigName)
|
|
}
|
|
|
|
// Determine target path in container
|
|
targetPath := m.ContainerPath
|
|
if targetPath == "" {
|
|
// This is the default from the Compose spec
|
|
targetPath = filepath.Join("/", m.ConfigName)
|
|
}
|
|
|
|
// Determine file mode
|
|
fileMode := os.FileMode(0o444) // Default permissions
|
|
if m.Mode != nil {
|
|
fileMode = *m.Mode
|
|
}
|
|
|
|
uid, err := m.GetNumericUid()
|
|
if err != nil {
|
|
return fmt.Errorf("invalid Uid: %w", err)
|
|
}
|
|
|
|
gid, err := m.GetNumericGid()
|
|
if err != nil {
|
|
return fmt.Errorf("invalid Gid: %w", err)
|
|
}
|
|
|
|
// Copy the config content directly into the container
|
|
if err := s.copyContentToContainer(
|
|
ctx, containerID, config.Content, targetPath, uid, gid, fileMode,
|
|
); err != nil {
|
|
return fmt.Errorf("copy config file '%s' to container: %w", config.Name, err)
|
|
}
|
|
|
|
slog.Debug("Injected config into container",
|
|
"config", config.Name,
|
|
"container", containerID[:12],
|
|
"target", targetPath)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copyContentToContainer copies content directly to a file in the container using Docker's CopyToContainer API.
|
|
// It will create any intermediate directories in the target path that don't exist.
|
|
func (s *Server) copyContentToContainer(ctx context.Context, containerID string, content []byte, targetPath string, uid *uint64, gid *uint64, fileMode os.FileMode) error {
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
|
|
// Trim leading slash(es) to avoid double slashes in the tar path
|
|
tarPath := strings.TrimPrefix(targetPath, "/")
|
|
|
|
// Create tar header with full path
|
|
header := &tar.Header{
|
|
Name: tarPath,
|
|
Size: int64(len(content)),
|
|
Mode: int64(fileMode),
|
|
ModTime: time.Now(),
|
|
Typeflag: tar.TypeReg,
|
|
}
|
|
|
|
// Set ownership if specified
|
|
if uid != nil {
|
|
header.Uid = int(*uid)
|
|
}
|
|
if gid != nil {
|
|
header.Gid = int(*gid)
|
|
}
|
|
|
|
// Write header and content to tar archive
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
return fmt.Errorf("write tar header: %w", err)
|
|
}
|
|
if _, err := tw.Write(content); err != nil {
|
|
return fmt.Errorf("write content to tar: %w", err)
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
return fmt.Errorf("close tar writer: %w", err)
|
|
}
|
|
|
|
// Always extract to root. The tar archive contains the full path, so tar will
|
|
// automatically create any intermediate directories that don't exist in the container.
|
|
if err := s.client.CopyToContainer(
|
|
ctx,
|
|
containerID,
|
|
"/",
|
|
&buf,
|
|
container.CopyToContainerOptions{CopyUIDGID: true},
|
|
); err != nil {
|
|
return fmt.Errorf("copy to container: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
|
if opts == nil {
|
|
return nil
|
|
}
|
|
|
|
dockerOpts := &mount.BindOptions{
|
|
Propagation: opts.Propagation,
|
|
CreateMountpoint: opts.CreateHostPath,
|
|
}
|
|
|
|
switch opts.Recursive {
|
|
case "disabled":
|
|
dockerOpts.NonRecursive = true
|
|
case "writable":
|
|
dockerOpts.ReadOnlyNonRecursive = true
|
|
case "readonly":
|
|
dockerOpts.ReadOnlyForceRecursive = true
|
|
}
|
|
|
|
return dockerOpts
|
|
}
|
|
|
|
func toDockerUlimits(ulimits map[string]api.Ulimit) []*units.Ulimit {
|
|
if len(ulimits) == 0 {
|
|
return nil
|
|
}
|
|
|
|
dockerUlimits := make([]*units.Ulimit, 0, len(ulimits))
|
|
for name, u := range ulimits {
|
|
dockerUlimits = append(dockerUlimits, &units.Ulimit{
|
|
Name: name,
|
|
Soft: u.Soft,
|
|
Hard: u.Hard,
|
|
})
|
|
}
|
|
|
|
return dockerUlimits
|
|
}
|
|
|
|
func toDockerDevices(devices []api.DeviceMapping) []container.DeviceMapping {
|
|
if len(devices) == 0 {
|
|
return nil
|
|
}
|
|
|
|
dockerDevices := make([]container.DeviceMapping, 0, len(devices))
|
|
for _, d := range devices {
|
|
dockerDevices = append(dockerDevices, container.DeviceMapping{
|
|
PathOnHost: d.HostPath,
|
|
PathInContainer: d.ContainerPath,
|
|
CgroupPermissions: d.CgroupPermissions,
|
|
})
|
|
}
|
|
|
|
return dockerDevices
|
|
}
|
|
|
|
// verifyDockerVolumesExist checks if the Docker named volumes referenced in the mounts exist on the machine.
|
|
func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mount) error {
|
|
for _, m := range mounts {
|
|
if m.Type != mount.TypeVolume {
|
|
continue
|
|
}
|
|
|
|
// TODO: non-local volume drivers should likely be handled differently (needs proper investigation).
|
|
if _, err := s.client.VolumeInspect(ctx, m.Source); err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return status.Errorf(codes.NotFound, "volume '%s' not found", m.Source)
|
|
}
|
|
return status.Errorf(codes.Internal, "inspect volume '%s': %v", m.Source, err.Error())
|
|
}
|
|
// TODO: check if the volume driver and options are the same as in the mount and fail if not.
|
|
// Should we even ignore driver-specific options in the volume spec for externally managed volumes?
|
|
// Instead, just inspect the existing volume and construct the mount from it.
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// InspectServiceContainer returns the container information and service specification that was used to create the
|
|
// container with the given ID.
|
|
func (s *Server) InspectServiceContainer(
|
|
ctx context.Context, req *pb.InspectContainerRequest,
|
|
) (*pb.ServiceContainer, error) {
|
|
serviceCtr, err := s.service.InspectServiceContainer(ctx, req.Id)
|
|
if err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
ctrBytes, err := json.Marshal(serviceCtr.Container)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal container: %v", err)
|
|
}
|
|
|
|
specBytes, err := json.Marshal(serviceCtr.ServiceSpec)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
|
|
}
|
|
|
|
return &pb.ServiceContainer{
|
|
Container: ctrBytes,
|
|
ServiceSpec: specBytes,
|
|
}, nil
|
|
}
|
|
|
|
// ListServiceContainers returns all containers that belong to the service with the given name or ID.
|
|
// If req.ServiceId is empty, all service containers are returned.
|
|
func (s *Server) ListServiceContainers(
|
|
ctx context.Context, req *pb.ListServiceContainersRequest,
|
|
) (*pb.ListServiceContainersResponse, error) {
|
|
var opts container.ListOptions
|
|
if len(req.Options) > 0 {
|
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
|
}
|
|
|
|
// Handle filters separately because they implement custom JSON unmarshalling.
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(req.Options, &raw); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
|
|
}
|
|
|
|
if filtersBytes, ok := raw["Filters"]; ok {
|
|
args, err := filters.FromJSON(string(filtersBytes))
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
|
}
|
|
opts.Filters = args
|
|
}
|
|
}
|
|
|
|
containers, err := s.service.ListServiceContainers(ctx, req.ServiceId, opts)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
// Convert to protobuf format.
|
|
pbContainers := make([]*pb.ServiceContainer, 0, len(containers))
|
|
for _, ctr := range containers {
|
|
ctrBytes, err := json.Marshal(ctr.Container)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal container: %v", err)
|
|
}
|
|
|
|
specBytes, err := json.Marshal(ctr.ServiceSpec)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "marshal service spec: %v", err)
|
|
}
|
|
|
|
pbContainers = append(pbContainers, &pb.ServiceContainer{
|
|
Container: ctrBytes,
|
|
ServiceSpec: specBytes,
|
|
})
|
|
}
|
|
|
|
return &pb.ListServiceContainersResponse{
|
|
Messages: []*pb.MachineServiceContainers{
|
|
{
|
|
Containers: pbContainers,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// RemoveServiceContainer stops (kills after grace period) and removes a service container with the given ID.
|
|
// The difference between this method and RemoveContainer is that it also removes the container from the machine
|
|
// database.
|
|
func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveContainerRequest) (*emptypb.Empty, error) {
|
|
ctrID := req.Id
|
|
// If the ID is not a full Docker ID, inspect the container to get its full ID.
|
|
if !fullDockerIDRegex.MatchString(req.Id) {
|
|
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
|
if err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
ctrID = ctr.ID
|
|
}
|
|
|
|
resp, err := s.RemoveContainer(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err = s.db.ExecContext(ctx, `DELETE FROM containers WHERE id = $1`, ctrID); err != nil {
|
|
slog.Error("Failed to remove container from machine database.", "err", err, "id", ctrID)
|
|
// Do not return an error because the container has already been removed from the Docker daemon.
|
|
// The orphaned db record will be ignored and eventually cleaned up by the garbage collector.
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// logsHeartbeatInterval is the interval at which heartbeat entries are sent when there are no logs to stream.
|
|
const logsHeartbeatInterval = 200 * time.Millisecond
|
|
|
|
// ContainerLogs streams logs from a container.
|
|
func (s *Server) ContainerLogs(
|
|
req *pb.LogsRequest, stream grpc.ServerStreamingServer[pb.LogEntry],
|
|
) error {
|
|
// Stream context is cancelled when the client has disconnected or the stream has ended.
|
|
ctx := stream.Context()
|
|
|
|
opts := api.ServiceLogsOptions{
|
|
Follow: req.Follow,
|
|
Tail: int(req.Tail),
|
|
Since: req.Since,
|
|
Until: req.Until,
|
|
}
|
|
|
|
logsCh, err := s.service.ContainerLogs(ctx, req.Id, opts)
|
|
if err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return status.Errorf(codes.Internal, "get container logs: %v", err)
|
|
}
|
|
|
|
log := slog.With("container_id", req.Id, "stream_id", fmt.Sprintf("%p", stream)[2:])
|
|
log.Debug("Starting container logs streaming.",
|
|
"follow", req.Follow, "tail", req.Tail, "since", req.Since, "until", req.Until)
|
|
|
|
// Heartbeats are needed only when following logs to let the client know when there are no new log entries
|
|
// to allow it to advance the watermark of last received log timestamp.
|
|
var heartbeatCh <-chan time.Time
|
|
if req.Follow {
|
|
heartbeatTicker := time.NewTicker(logsHeartbeatInterval)
|
|
defer heartbeatTicker.Stop()
|
|
heartbeatCh = heartbeatTicker.C
|
|
}
|
|
|
|
started := time.Now()
|
|
lastSent := time.Time{}
|
|
|
|
for {
|
|
select {
|
|
case entry, ok := <-logsCh:
|
|
if !ok {
|
|
// Channel closed, no more log entries.
|
|
return nil
|
|
}
|
|
|
|
if entry.Err != nil {
|
|
return status.Error(codes.Internal, entry.Err.Error())
|
|
}
|
|
|
|
pbEntry := &pb.LogEntry{
|
|
Stream: api.LogStreamTypeToProto(entry.Stream),
|
|
Timestamp: timestamppb.New(entry.Timestamp),
|
|
Message: entry.Message,
|
|
}
|
|
if err = stream.Send(pbEntry); err != nil {
|
|
return status.Errorf(codes.Internal, "send log entry: %v", err)
|
|
}
|
|
lastSent = entry.Timestamp
|
|
|
|
case now := <-heartbeatCh:
|
|
// Only send heartbeat if no log entries have been sent since the last heartbeat interval or
|
|
// if no log entries have been sent at all for at least a heartbeat interval since starting.
|
|
if now.Sub(lastSent) < logsHeartbeatInterval ||
|
|
(lastSent.IsZero() && now.Sub(started) < logsHeartbeatInterval) {
|
|
continue
|
|
}
|
|
|
|
// Use the timestamp one heartbeat in the past to be conservative. This reduces the chance of sending
|
|
// a timestamp that is greater than a log entry currently being parsed but not yet sent, which would
|
|
// cause the client to incorrectly believe it has received all logs up to that point.
|
|
heartbeat := &pb.LogEntry{
|
|
Stream: pb.LogEntry_HEARTBEAT,
|
|
Timestamp: timestamppb.New(now.Add(-logsHeartbeatInterval)),
|
|
}
|
|
if err = stream.Send(heartbeat); err != nil {
|
|
return status.Errorf(codes.Internal, "send log stream heartbeat: %v", err)
|
|
}
|
|
lastSent = heartbeat.Timestamp.AsTime()
|
|
log.Debug("Sent log stream heartbeat.", "timestamp", lastSent)
|
|
|
|
case <-ctx.Done():
|
|
return status.Error(codes.Canceled, ctx.Err().Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
// receiveExecConfig receives and validates the initial exec configuration from the stream.
|
|
func (s *Server) receiveExecConfig(stream pb.Docker_ExecContainerServer) (*pb.ExecConfig, api.ExecOptions, error) {
|
|
req, err := stream.Recv()
|
|
if err != nil {
|
|
return nil, api.ExecOptions{}, status.Errorf(codes.InvalidArgument, "receive config: %v", err)
|
|
}
|
|
|
|
execConfig := req.GetConfig()
|
|
if execConfig == nil {
|
|
return nil, api.ExecOptions{}, status.Error(codes.InvalidArgument, "first message must contain exec config")
|
|
}
|
|
|
|
// Unmarshal the Uncloud's execOpts
|
|
var execOpts api.ExecOptions
|
|
if err := json.Unmarshal(execConfig.Options, &execOpts); err != nil {
|
|
return nil, api.ExecOptions{}, status.Errorf(codes.InvalidArgument, "unmarshal exec config: %v", err)
|
|
}
|
|
|
|
return execConfig, execOpts, nil
|
|
}
|
|
|
|
// handleServerExecInput reads from the gRPC stream and writes to Docker stdin, handling resize requests.
|
|
func (s *Server) handleServerExecInput(
|
|
ctx context.Context,
|
|
stream pb.Docker_ExecContainerServer,
|
|
attachConn types.HijackedResponse,
|
|
execID string,
|
|
tty bool,
|
|
) error {
|
|
slog.Debug("Input goroutine started", "exec_id", execID, "tty", tty)
|
|
defer slog.Debug("Input goroutine exited", "exec_id", execID)
|
|
|
|
defer attachConn.CloseWrite()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
slog.Debug("Input goroutine context canceled via context", "exec_id", execID)
|
|
return nil
|
|
default:
|
|
}
|
|
|
|
req, err := stream.Recv()
|
|
switch {
|
|
case errors.Is(err, io.EOF):
|
|
slog.Debug("Input goroutine received EOF", "exec_id", execID)
|
|
return nil
|
|
case status.Code(err) == codes.Canceled:
|
|
// Can be the case when the output goroutine ends and the stream context is canceled.
|
|
slog.Debug("Input goroutine context canceled", "exec_id", execID)
|
|
return nil
|
|
case err == nil:
|
|
// continue processing
|
|
default:
|
|
return fmt.Errorf("receive from stream: %w", err)
|
|
}
|
|
|
|
switch payload := req.Payload.(type) {
|
|
case *pb.ExecContainerRequest_Stdin:
|
|
if _, err := attachConn.Conn.Write(payload.Stdin); err != nil {
|
|
return fmt.Errorf("write to stdin: %w", err)
|
|
}
|
|
case *pb.ExecContainerRequest_Resize:
|
|
if tty {
|
|
resizeOpts := container.ResizeOptions{
|
|
Height: uint(payload.Resize.Height),
|
|
Width: uint(payload.Resize.Width),
|
|
}
|
|
if err := s.client.ContainerExecResize(ctx, execID, resizeOpts); err != nil {
|
|
slog.Warn("Failed to resize TTY", "err", err, "exec_id", execID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleServerExecOutput reads from Docker stdout/stderr and writes to the gRPC stream.
|
|
func (s *Server) handleServerExecOutput(
|
|
stream pb.Docker_ExecContainerServer,
|
|
attachResp types.HijackedResponse,
|
|
execID string,
|
|
tty bool,
|
|
) error {
|
|
slog.Debug("Output goroutine started", "exec_id", execID, "tty", tty)
|
|
defer slog.Debug("Output goroutine exited", "exec_id", execID)
|
|
|
|
if tty {
|
|
// In TTY mode, all output is stdout - copy directly to stream
|
|
stdoutWriter := &grpcStreamWriter{stream: stream, isStderr: false}
|
|
_, err := io.Copy(stdoutWriter, attachResp.Reader)
|
|
if err != nil && err != io.EOF {
|
|
return fmt.Errorf("copy tty output: %w", err)
|
|
}
|
|
return nil
|
|
} else {
|
|
// In non-TTY mode, Docker multiplexes stdout/stderr with headers
|
|
// Use stdcopy to demultiplex
|
|
slog.Debug("Starting StdCopy for non-TTY", "exec_id", execID)
|
|
stdoutWriter := &grpcStreamWriter{stream: stream, isStderr: false}
|
|
stderrWriter := &grpcStreamWriter{stream: stream, isStderr: true}
|
|
|
|
written, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, attachResp.Reader)
|
|
slog.Debug("StdCopy completed", "exec_id", execID, "bytes", written, "err", err)
|
|
if err != nil && err != io.EOF {
|
|
return fmt.Errorf("demultiplex docker output: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// grpcStreamWriter is a writer that sends data to a gRPC stream as stdout or stderr.
|
|
type grpcStreamWriter struct {
|
|
stream pb.Docker_ExecContainerServer
|
|
isStderr bool
|
|
}
|
|
|
|
func (w *grpcStreamWriter) Write(p []byte) (n int, err error) {
|
|
data := make([]byte, len(p))
|
|
copy(data, p)
|
|
|
|
var resp *pb.ExecContainerResponse
|
|
if w.isStderr {
|
|
resp = &pb.ExecContainerResponse{
|
|
Payload: &pb.ExecContainerResponse_Stderr{Stderr: data},
|
|
}
|
|
} else {
|
|
resp = &pb.ExecContainerResponse{
|
|
Payload: &pb.ExecContainerResponse_Stdout{Stdout: data},
|
|
}
|
|
}
|
|
|
|
if err := w.stream.Send(resp); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// ExecContainer executes a command in a running container with bidirectional streaming for stdin/stdout/stderr.
|
|
func (s *Server) ExecContainer(stream pb.Docker_ExecContainerServer) error {
|
|
slog.Debug("ExecContainer server-side called")
|
|
defer slog.Debug("ExecContainer server-side ended")
|
|
|
|
ctx := stream.Context()
|
|
|
|
// Receive and validate configuration
|
|
execConfig, execOpts, err := s.receiveExecConfig(stream)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Convert to Docker's ExecOptions
|
|
dockerExecOpts := container.ExecOptions{
|
|
Cmd: execOpts.Command,
|
|
AttachStdin: execOpts.AttachStdin,
|
|
AttachStdout: execOpts.AttachStdout,
|
|
AttachStderr: execOpts.AttachStderr,
|
|
Tty: execOpts.Tty,
|
|
User: execOpts.User,
|
|
Privileged: execOpts.Privileged,
|
|
WorkingDir: execOpts.WorkingDir,
|
|
Env: execOpts.Env,
|
|
}
|
|
|
|
// Create the exec instance
|
|
execResp, err := s.client.ContainerExecCreate(ctx, execConfig.ContainerId, dockerExecOpts)
|
|
if err != nil {
|
|
if errdefs.IsNotFound(err) {
|
|
return status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return status.Errorf(codes.Internal, "create exec: %v", err)
|
|
}
|
|
|
|
// Send the exec ID back to the client
|
|
if err := stream.Send(&pb.ExecContainerResponse{
|
|
Payload: &pb.ExecContainerResponse_ExecId{ExecId: execResp.ID},
|
|
}); err != nil {
|
|
return status.Errorf(codes.Internal, "send exec ID: %v", err)
|
|
}
|
|
slog.Debug("Sent exec ID to the client", "exec_id", execResp.ID)
|
|
|
|
// For detached mode, start without attaching
|
|
if execOpts.Detach {
|
|
dockerStartOpts := container.ExecStartOptions{
|
|
Tty: dockerExecOpts.Tty,
|
|
Detach: true,
|
|
}
|
|
if err := s.client.ContainerExecStart(ctx, execResp.ID, dockerStartOpts); err != nil {
|
|
return status.Errorf(codes.Internal, "start exec: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// For attached mode, attach to the exec instance
|
|
attachOpts := container.ExecAttachOptions{
|
|
Tty: dockerExecOpts.Tty,
|
|
}
|
|
attachConn, err := s.client.ContainerExecAttach(ctx, execResp.ID, attachOpts)
|
|
if err != nil {
|
|
return status.Errorf(codes.Internal, "attach to exec: %v", err)
|
|
}
|
|
defer attachConn.Close()
|
|
|
|
// Create a cancelable context for the input handler
|
|
handlerCtx, cancelInput := context.WithCancel(ctx)
|
|
defer cancelInput()
|
|
|
|
// Create a channel to wait for output completion
|
|
outputDone := make(chan error, 1)
|
|
|
|
// Start stdin handler if stdin is attached
|
|
if dockerExecOpts.AttachStdin {
|
|
go func() {
|
|
err := s.handleServerExecInput(handlerCtx, stream, attachConn, execResp.ID, dockerExecOpts.Tty)
|
|
if err != nil {
|
|
slog.Warn("Error in exec input handler", "err", err, "exec_id", execResp.ID)
|
|
}
|
|
}()
|
|
} else {
|
|
// If not attaching stdin, close the write side immediately
|
|
attachConn.CloseWrite()
|
|
}
|
|
|
|
// Start output handler
|
|
// We only wait for this goroutine to complete - it signals when the exec process finishes
|
|
go func() {
|
|
outputDone <- s.handleServerExecOutput(stream, attachConn, execResp.ID, dockerExecOpts.Tty)
|
|
}()
|
|
|
|
// Wait for the output goroutine to complete (it signals when done)
|
|
// We only wait for the output handler goroutine, not for the stdin one.
|
|
if err := <-outputDone; err != nil {
|
|
slog.Warn("Error in exec output handler", "err", err, "exec_id", execResp.ID)
|
|
}
|
|
// Do a best-effort cancellation of the stdin handler.
|
|
// We can't guarantee immediate exit because it may be blocked on stream.Recv(), but at
|
|
// least we want to send a cancel signal explicitly.
|
|
cancelInput()
|
|
|
|
inspectResp, err := s.client.ContainerExecInspect(ctx, execResp.ID)
|
|
if err != nil {
|
|
slog.Error("Failed to inspect exec after completion", "err", err, "exec_id", execResp.ID)
|
|
return status.Errorf(codes.Internal, "inspect exec: %v", err)
|
|
}
|
|
|
|
// Send the exit code
|
|
slog.Debug("Sending exec exit code", "exec_id", execResp.ID, "exit_code", inspectResp.ExitCode)
|
|
if err := stream.Send(&pb.ExecContainerResponse{
|
|
Payload: &pb.ExecContainerResponse_ExitCode{ExitCode: int32(inspectResp.ExitCode)},
|
|
}); err != nil {
|
|
slog.Error("Failed to send exec exit code", "err", err, "exec_id", execResp.ID)
|
|
return status.Errorf(codes.Internal, "send exit code: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|