586 lines
17 KiB
Go
586 lines
17 KiB
Go
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
|
|
|
|
// Container resource
|
|
package resource
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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/mount"
|
|
"github.com/docker/docker/api/types/network"
|
|
"github.com/docker/docker/api/types/strslice"
|
|
"github.com/docker/docker/client"
|
|
"github.com/docker/go-connections/nat"
|
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
|
"gopkg.in/yaml.v3"
|
|
_ "gopkg.in/yaml.v3"
|
|
"log/slog"
|
|
"net/url"
|
|
_ "os"
|
|
_ "os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"encoding/json"
|
|
"io"
|
|
"gitea.rosskeen.house/rosskeen.house/machine"
|
|
"decl/internal/codec"
|
|
"decl/internal/data"
|
|
"decl/internal/folio"
|
|
"decl/internal/containerlog"
|
|
"bytes"
|
|
_ "encoding/base64"
|
|
)
|
|
|
|
const (
|
|
ContainerTypeName TypeName = "container"
|
|
)
|
|
|
|
type ContainerClient interface {
|
|
ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error)
|
|
ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error
|
|
ContainerList(context.Context, container.ListOptions) ([]types.Container, error)
|
|
ContainerInspect(context.Context, string) (types.ContainerJSON, error)
|
|
ContainerRemove(context.Context, string, container.RemoveOptions) error
|
|
ContainerStop(context.Context, string, container.StopOptions) error
|
|
ContainerWait(ctx context.Context, containerID string, condition container.WaitCondition) (<-chan container.WaitResponse, <-chan error)
|
|
ContainerLogs(ctx context.Context, containerID string, options container.LogsOptions) (io.ReadCloser, error)
|
|
Close() error
|
|
}
|
|
|
|
type Container struct {
|
|
*Common `yaml:",inline" json:",inline"`
|
|
stater machine.Stater `yaml:"-" json:"-"`
|
|
Id string `json:"ID,omitempty" yaml:"ID,omitempty"`
|
|
Name string `json:"name" yaml:"name"`
|
|
Cmd []string `json:"cmd,omitempty" yaml:"cmd,omitempty"`
|
|
WorkingDir string `json:"workingdir,omitempty" yaml:"workingdir,omitempty"`
|
|
Entrypoint strslice.StrSlice `json:"entrypoint,omitempty" yaml:"entrypoint,omitempty"`
|
|
Args []string `json:"args,omitempty" yaml:"args,omitempty"`
|
|
Ports []string `json:"ports,omitempty" yaml:"ports,omitempty"`
|
|
Environment map[string]string `json:"environment" yaml:"environment"`
|
|
Image string `json:"image" yaml:"image"`
|
|
ResolvConfPath string `json:"resolvconfpath" yaml:"resolvconfpath"`
|
|
HostnamePath string `json:"hostnamepath" yaml:"hostnamepath"`
|
|
HostsPath string `json:"hostpath" yaml:"hostspath"`
|
|
LogPath string `json:"logpath" yaml:"logpath"`
|
|
Created string `json:"created" yaml:"created"`
|
|
ContainerState types.ContainerState `json:"containerstate" yaml:"containerstate"`
|
|
RestartCount int `json:"restartcount" yaml:"restartcount"`
|
|
Driver string `json:"driver" yaml:"driver"`
|
|
Platform string `json:"platform" yaml:"platform"`
|
|
MountLabel string `json:"mountlabel" yaml:"mountlabel"`
|
|
ProcessLabel string `json:"processlabel" yaml:"processlabel"`
|
|
AppArmorProfile string `json:"apparmorprofile" yaml:"apparmorprofile"`
|
|
ExecIDs []string `json:"execids" yaml:"execids"`
|
|
HostConfig container.HostConfig `json:"hostconfig" yaml:"hostconfig"`
|
|
GraphDriver types.GraphDriverData `json:"graphdriver" yaml:"graphdriver"`
|
|
SizeRw *int64 `json:",omitempty" yaml:",omitempty"`
|
|
SizeRootFs *int64 `json:",omitempty" yaml:",omitempty"`
|
|
Networks []string `json:"networks,omitempty" yaml:"networks,omitempty"`
|
|
/*
|
|
Mounts []MountPoint
|
|
Config *container.Config
|
|
NetworkSettings *NetworkSettings
|
|
*/
|
|
|
|
Wait bool `json:"wait,omitempty" yaml:"wait,omitempty"`
|
|
Stdout string `json:"stdout,omitempty" yaml:"stdout,omitempty"`
|
|
Stderr string `json:"stderr,omitempty" yaml:"stderr,omitempty"`
|
|
|
|
apiClient ContainerClient
|
|
Resources data.ResourceMapper `json:"-" yaml:"-"`
|
|
}
|
|
|
|
func init() {
|
|
ResourceTypes.Register([]string{"container"}, func(u *url.URL) (c data.Resource) {
|
|
c = NewContainer(nil)
|
|
if u != nil {
|
|
if err := folio.CastParsedURI(u).ConstructResource(c); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
return
|
|
})
|
|
}
|
|
|
|
func NewContainer(containerClientApi ContainerClient) *Container {
|
|
var apiClient ContainerClient = containerClientApi
|
|
if apiClient == nil {
|
|
var err error
|
|
apiClient, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
return &Container{
|
|
Common: NewCommon(ContainerTypeName, false),
|
|
apiClient: apiClient,
|
|
}
|
|
}
|
|
|
|
func (c *Container) Init(u data.URIParser) error {
|
|
if u == nil {
|
|
u = folio.URI(c.URI()).Parse()
|
|
}
|
|
uri := u.URL()
|
|
c.Name = filepath.Join(uri.Hostname(), uri.Path)
|
|
return c.SetParsedURI(u)
|
|
}
|
|
|
|
func (c *Container) SetParsedURI(u data.URIParser) (err error) {
|
|
if err = c.Common.SetParsedURI(u); err == nil {
|
|
c.Name = filepath.Join(c.Common.parsedURI.Hostname(), c.Common.parsedURI.Path)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (c *Container) SetResourceMapper(resources data.ResourceMapper) {
|
|
c.Resources = resources
|
|
}
|
|
|
|
func (c *Container) Clone() data.Resource {
|
|
return &Container {
|
|
Id: c.Id,
|
|
Name: c.Name,
|
|
Common: c.Common.Clone(),
|
|
Cmd: c.Cmd,
|
|
Entrypoint: c.Entrypoint,
|
|
Args: c.Args,
|
|
Environment: c.Environment,
|
|
Image: c.Image,
|
|
ResolvConfPath: c.ResolvConfPath,
|
|
HostnamePath: c.HostnamePath,
|
|
HostsPath: c.HostsPath,
|
|
LogPath: c.LogPath,
|
|
Created: c.Created,
|
|
ContainerState: c.ContainerState,
|
|
RestartCount: c.RestartCount,
|
|
Driver: c.Driver,
|
|
Platform: c.Platform,
|
|
MountLabel: c.MountLabel,
|
|
ProcessLabel: c.ProcessLabel,
|
|
AppArmorProfile: c.AppArmorProfile,
|
|
ExecIDs: c.ExecIDs,
|
|
HostConfig: c.HostConfig,
|
|
GraphDriver: c.GraphDriver,
|
|
SizeRw: c.SizeRw,
|
|
SizeRootFs: c.SizeRootFs,
|
|
Networks: c.Networks,
|
|
apiClient: c.apiClient,
|
|
}
|
|
}
|
|
|
|
func (c *Container) StateMachine() machine.Stater {
|
|
if c.stater == nil {
|
|
c.stater = ProcessMachine(c)
|
|
}
|
|
return c.stater
|
|
}
|
|
|
|
func (c *Container) JSON() ([]byte, error) {
|
|
return json.Marshal(c)
|
|
}
|
|
|
|
func (c *Container) Validate() error {
|
|
return fmt.Errorf("failed")
|
|
}
|
|
|
|
func (c *Container) Notify(m *machine.EventMessage) {
|
|
slog.Info("Container.Notify()", "destination_event", m.Dest, "uri", c.URI())
|
|
ctx := context.Background()
|
|
switch m.On {
|
|
case machine.ENTERSTATEEVENT:
|
|
switch m.Dest {
|
|
case "start_stat":
|
|
if statErr := c.ReadStat(); statErr == nil {
|
|
slog.Info("Container.Notify() - ReadStat", "event", "start_stat", "error", statErr)
|
|
if triggerErr := c.StateMachine().Trigger("exists"); triggerErr == nil {
|
|
slog.Info("Container.Notify()", "event", "start_stat", "trigger", "exists")
|
|
return
|
|
}
|
|
} else {
|
|
slog.Info("Container.Notify() - ReadStat", "event", "start_stat", "error", statErr)
|
|
if triggerErr := c.StateMachine().Trigger("notexists"); triggerErr == nil {
|
|
slog.Info("Container.Notify()", "event", "start_stat", "trigger", "notexists")
|
|
return
|
|
}
|
|
}
|
|
case "start_read":
|
|
if _,readErr := c.Read(ctx); readErr == nil {
|
|
if triggerErr := c.stater.Trigger("state_read"); triggerErr == nil {
|
|
return
|
|
} else {
|
|
c.Common.State = "absent"
|
|
panic(triggerErr)
|
|
}
|
|
} else {
|
|
c.Common.State = "absent"
|
|
panic(readErr)
|
|
}
|
|
case "start_create":
|
|
if createErr := c.Create(ctx); createErr == nil {
|
|
if triggerErr := c.StateMachine().Trigger("created"); triggerErr == nil {
|
|
return
|
|
} else {
|
|
c.Common.State = "absent"
|
|
panic(triggerErr)
|
|
}
|
|
} else {
|
|
c.Common.State = "absent"
|
|
panic(createErr)
|
|
}
|
|
case "start_delete":
|
|
slog.Info("Container.Notify()", "event", "start_delete")
|
|
if deleteErr := c.Delete(ctx); deleteErr == nil {
|
|
if triggerErr := c.StateMachine().Trigger("deleted"); triggerErr == nil {
|
|
return
|
|
} else {
|
|
c.Common.State = "present"
|
|
panic(triggerErr)
|
|
}
|
|
} else {
|
|
c.Common.State = "present"
|
|
panic(deleteErr)
|
|
}
|
|
case "present", "created", "read":
|
|
c.Common.State = "present"
|
|
case "running":
|
|
c.Common.State = "running"
|
|
case "absent":
|
|
c.Common.State = "absent"
|
|
}
|
|
case machine.EXITSTATEEVENT:
|
|
}
|
|
}
|
|
|
|
func (c *Container) ReadStat() (err error) {
|
|
err = fmt.Errorf("%w: %s", ErrResourceStateAbsent, c.Name)
|
|
filterArgs := filters.NewArgs()
|
|
filterArgs.Add("name", "/"+c.Name)
|
|
if containers, listErr := c.apiClient.ContainerList(context.Background(), container.ListOptions{
|
|
All: true,
|
|
Filters: filterArgs,
|
|
}); listErr == nil {
|
|
for _, container := range containers {
|
|
for _, containerName := range container.Names {
|
|
if containerName == "/"+c.Name {
|
|
slog.Info("Container.ReadStat() exists", "container", c.Name)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
err = fmt.Errorf("%w: %w", err, listErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (c *Container) Apply() error {
|
|
ctx := context.Background()
|
|
switch c.Common.State {
|
|
case "absent":
|
|
return c.Delete(ctx)
|
|
case "present":
|
|
return c.Create(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Container) Load(docData []byte, f codec.Format) (err error) {
|
|
err = f.StringDecoder(string(docData)).Decode(c)
|
|
return
|
|
}
|
|
|
|
func (c *Container) LoadReader(r io.ReadCloser, f codec.Format) (err error) {
|
|
err = f.Decoder(r).Decode(c)
|
|
return
|
|
}
|
|
|
|
func (c *Container) LoadString(docData string, f codec.Format) (err error) {
|
|
err = f.StringDecoder(docData).Decode(c)
|
|
return
|
|
}
|
|
|
|
func (c *Container) LoadDecl(yamlResourceDeclaration string) error {
|
|
return c.LoadString(yamlResourceDeclaration, codec.FormatYaml)
|
|
}
|
|
|
|
func (c *Container) ReadFromContainer(ctx context.Context) (err error) {
|
|
var buf bytes.Buffer
|
|
var stdout, stderr []string
|
|
|
|
if stdoutReader, err := c.apiClient.ContainerLogs(ctx, c.Id, container.LogsOptions{ShowStdout: true, ShowStderr: true}); err != nil {
|
|
return err
|
|
} else {
|
|
defer stdoutReader.Close()
|
|
|
|
if _, copyErr := io.Copy(&buf, stdoutReader); copyErr != nil {
|
|
return copyErr
|
|
}
|
|
slog.Info("Container.ReadFromContainer() - ContainerLogs", "read", buf.String())
|
|
|
|
for {
|
|
if streamType, message, extractErr := containerlog.Read(&buf); extractErr == nil {
|
|
switch streamType {
|
|
case containerlog.StreamStdout:
|
|
stdout = append(stdout, message)
|
|
case containerlog.StreamStderr:
|
|
stderr = append(stderr, message)
|
|
}
|
|
} else {
|
|
if extractErr == io.EOF {
|
|
break
|
|
}
|
|
err = extractErr
|
|
}
|
|
/*
|
|
if streamType, size, extractErr := c.ExtractLogHeader(&buf); extractErr == nil {
|
|
slog.Info("Container.Create() - ContainerLogs", "streamtype", streamType, "size", size)
|
|
var logMessage string
|
|
if logMessage, err = c.ReadLogMessage(&buf, size); err == nil {
|
|
switch streamType {
|
|
case ContainerLogStreamStdout:
|
|
stdout = append(stdout, logMessage)
|
|
case ContainerLogStreamStderr:
|
|
stderr = append(stderr, logMessage)
|
|
}
|
|
}
|
|
} else {
|
|
if extractErr == io.EOF {
|
|
break
|
|
}
|
|
err = extractErr
|
|
}
|
|
*/
|
|
}
|
|
}
|
|
c.Stdout = strings.Join(stdout, "")
|
|
c.Stderr = strings.Join(stderr, "")
|
|
slog.Info("Container.ReadFromContainer()", "stdout", c.Stdout, "stderr", c.Stderr, "error", err)
|
|
return
|
|
}
|
|
|
|
func (c *Container) Create(ctx context.Context) error {
|
|
numberOfEnvironmentVariables := len(c.Environment)
|
|
|
|
portset := nat.PortSet {}
|
|
for _, port := range c.Ports {
|
|
portset[nat.Port(port)] = struct{}{}
|
|
}
|
|
config := &container.Config{
|
|
Image: c.Image,
|
|
Cmd: c.Cmd,
|
|
Entrypoint: c.Entrypoint,
|
|
Tty: false,
|
|
ExposedPorts: portset,
|
|
WorkingDir: c.WorkingDir,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
}
|
|
|
|
config.Env = make([]string, numberOfEnvironmentVariables)
|
|
index := 0
|
|
for k, v := range c.Environment {
|
|
config.Env[index] = k + "=" + v
|
|
index++
|
|
}
|
|
for i := range c.HostConfig.Mounts {
|
|
if c.HostConfig.Mounts[i].Type == mount.TypeBind {
|
|
if mountSourceAbsolutePath, e := filepath.Abs(c.HostConfig.Mounts[i].Source); e == nil {
|
|
c.HostConfig.Mounts[i].Source = mountSourceAbsolutePath
|
|
}
|
|
}
|
|
}
|
|
|
|
networkConfig := &network.NetworkingConfig{
|
|
EndpointsConfig: map[string]*network.EndpointSettings{},
|
|
}
|
|
|
|
settings := &network.EndpointSettings{}
|
|
for _, network := range c.Networks {
|
|
networkConfig.EndpointsConfig[network] = settings
|
|
}
|
|
|
|
resp, err := c.apiClient.ContainerCreate(ctx, config, &c.HostConfig, networkConfig, nil, c.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.Id = resp.ID
|
|
|
|
if startErr := c.apiClient.ContainerStart(ctx, c.Id, container.StartOptions{}); startErr != nil {
|
|
return startErr
|
|
}
|
|
|
|
if err = c.ReadFromContainer(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.Wait {
|
|
slog.Info("Container.Create() - waiting for container to stop", "id", c.Id, "name", c.Name)
|
|
statusCh, errCh := c.apiClient.ContainerWait(ctx, c.Id, container.WaitConditionNotRunning)
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
case <-statusCh:
|
|
}
|
|
}
|
|
|
|
if len(c.Stdout) == 0 && len(c.Stderr) == 0 {
|
|
if err = c.ReadFromContainer(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (c *Container) Update(ctx context.Context) error {
|
|
return c.Create(ctx)
|
|
}
|
|
|
|
// produce yaml representation of any resource
|
|
|
|
func (c *Container) Read(ctx context.Context) ([]byte, error) {
|
|
var containerID string
|
|
filterArgs := filters.NewArgs()
|
|
filterArgs.Add("name", "/"+c.Name)
|
|
containers, err := c.apiClient.ContainerList(ctx, container.ListOptions{
|
|
All: true,
|
|
Filters: filterArgs,
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s %s", err, c.Type(), c.Name)
|
|
}
|
|
|
|
for _, container := range containers {
|
|
for _, containerName := range container.Names {
|
|
if containerName == "/"+c.Name {
|
|
containerID = container.ID
|
|
}
|
|
}
|
|
}
|
|
|
|
if inspectErr := c.Inspect(ctx, containerID); inspectErr != nil {
|
|
return nil, fmt.Errorf("%w: container %s", inspectErr, containerID)
|
|
}
|
|
slog.Info("Read() ", "type", c.Type(), "name", c.Name, "Id", c.Id)
|
|
return yaml.Marshal(c)
|
|
}
|
|
|
|
func (c *Container) Inspect(ctx context.Context, containerID string) error {
|
|
containerJSON, err := c.apiClient.ContainerInspect(ctx, containerID)
|
|
if client.IsErrNotFound(err) {
|
|
c.Common.State = "absent"
|
|
} else {
|
|
c.Common.State = "present"
|
|
c.Id = containerJSON.ID
|
|
if c.Name == "" {
|
|
if containerJSON.Name[0] == '/' {
|
|
c.Name = containerJSON.Name[1:]
|
|
} else {
|
|
c.Name = containerJSON.Name
|
|
}
|
|
}
|
|
c.Common.Path = containerJSON.Path
|
|
c.Image = containerJSON.Image
|
|
c.Created = containerJSON.Created
|
|
c.ResolvConfPath = containerJSON.ResolvConfPath
|
|
c.HostnamePath = containerJSON.HostnamePath
|
|
c.HostsPath = containerJSON.HostsPath
|
|
c.LogPath = containerJSON.LogPath
|
|
c.RestartCount = containerJSON.RestartCount
|
|
c.Driver = containerJSON.Driver
|
|
if containerJSON.State != nil {
|
|
c.ContainerState = *containerJSON.State
|
|
if c.ContainerState.ExitCode != 0 {
|
|
return fmt.Errorf("%s", c.ContainerState.Error)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Container) Delete(ctx context.Context) error {
|
|
slog.Info("Container.Delete()", "id", c.Id, "name", c.Name)
|
|
if stopErr := c.apiClient.ContainerStop(ctx, c.Id, container.StopOptions{}); stopErr != nil {
|
|
slog.Error("Container.Delete() - failed to stop: ", "Id", c.Id, "error", stopErr)
|
|
return stopErr
|
|
}
|
|
err := c.apiClient.ContainerRemove(ctx, c.Id, container.RemoveOptions{
|
|
RemoveVolumes: true,
|
|
Force: false,
|
|
})
|
|
if err != nil {
|
|
slog.Error("Container.Delete() - failed to remove: ", "Id", c.Id, "error", err)
|
|
return err
|
|
}
|
|
|
|
statusCh, errCh := c.apiClient.ContainerWait(ctx, c.Id, container.WaitConditionNotRunning)
|
|
select {
|
|
case waitErr := <-errCh:
|
|
if waitErr != nil {
|
|
if strings.Contains(waitErr.Error(), "No such container:") {
|
|
return nil
|
|
}
|
|
return waitErr
|
|
}
|
|
case <-statusCh:
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (c *Container) URI() string {
|
|
return fmt.Sprintf("%s://%s", c.Type(), c.Name)
|
|
}
|
|
|
|
func (c *Container) Type() string { return "container" }
|
|
|
|
func (c *Container) ResolveId(ctx context.Context) string {
|
|
var err error
|
|
|
|
if err = c.Common.SetParsedURI(folio.URI(c.URI()).Parse()); err != nil {
|
|
triggerErr := c.StateMachine().Trigger("notexists")
|
|
panic(fmt.Errorf("%w: %s %s, %w", err, c.Type(), c.Name, triggerErr))
|
|
}
|
|
|
|
filterArgs := filters.NewArgs()
|
|
filterArgs.Add("name", "/"+c.Name)
|
|
containers, listErr := c.apiClient.ContainerList(ctx, container.ListOptions{
|
|
All: true,
|
|
Filters: filterArgs,
|
|
})
|
|
|
|
if listErr != nil {
|
|
triggerErr := c.StateMachine().Trigger("notexists")
|
|
panic(fmt.Errorf("%w: %s %s, %w", listErr, c.Type(), c.Name, triggerErr))
|
|
}
|
|
|
|
slog.Info("Container.ResolveId()", "containers", containers)
|
|
for _, container := range containers {
|
|
for _, containerName := range container.Names {
|
|
if containerName == "/"+c.Name {
|
|
slog.Info("Container.ResolveId()", "state", c.StateMachine())
|
|
if c.Id == "" {
|
|
c.Id = container.ID
|
|
}
|
|
if triggerErr := c.StateMachine().Trigger("exists"); triggerErr != nil {
|
|
panic(fmt.Errorf("%w: %s %s", triggerErr, c.Type(), c.Name))
|
|
}
|
|
slog.Info("Container.ResolveId() trigger created", "machine", c.StateMachine(), "state", c.StateMachine().CurrentState())
|
|
return container.ID
|
|
}
|
|
}
|
|
}
|
|
if triggerErr := c.StateMachine().Trigger("notexists"); triggerErr != nil {
|
|
panic(fmt.Errorf("%w: %s %s", triggerErr, c.Type(), c.Name))
|
|
}
|
|
return ""
|
|
}
|