diff --git a/internal/resource/common.go b/internal/resource/common.go index 6034549..909963d 100644 --- a/internal/resource/common.go +++ b/internal/resource/common.go @@ -10,6 +10,7 @@ import ( "decl/internal/data" "decl/internal/folio" "log/slog" + "errors" ) type UriSchemeValidator func(scheme string) bool @@ -31,6 +32,7 @@ type Common struct { State string `json:"state,omitempty" yaml:"state,omitempty"` config data.ConfigurationValueGetter Resources data.ResourceMapper `json:"-" yaml:"-"` + Errors []error `json:"-" yaml:"-"` } func NewCommon(resourceType TypeName, includeQueryParams bool) *Common { @@ -100,6 +102,11 @@ func (c *Common) SetParsedURI(u data.URIParser) (err error) { } else { c.Path = filepath.Join(c.parsedURI.Hostname(), c.parsedURI.Path) } + if c.config != nil { + if prefixPath, configErr := c.config.GetValue("prefix"); configErr == nil { + c.Path = filepath.Join(prefixPath.(string), c.Path) + } + } if c.absPath, err = filepath.Abs(c.Path); err != nil { return } @@ -130,11 +137,6 @@ func (c *Common) ResolveId(ctx context.Context) string { // Common path normalization for a file resource. func (c *Common) NormalizeFilePath() (err error) { - if c.config != nil { - if prefixPath, configErr := c.config.GetValue("prefix"); configErr == nil { - c.Path = filepath.Join(prefixPath.(string), c.Path) - } - } if c.normalizePath { c.Path = c.absPath } @@ -142,3 +144,21 @@ func (c *Common) NormalizeFilePath() (err error) { } func (c *Common) Type() string { return string(c.resourceType) } + + +// If a resource update has errors but the resource is not actually absent +func (c *Common) IsResourceInconsistent() (result bool) { + for _, err := range c.Errors { + if ! errors.Is(err, ErrResourceStateAbsent) { + result = true + } + } + return +} + +func (c *Common) AddError(err error) (error) { + if err != nil { + c.Errors = append(c.Errors, err) + } + return err +} diff --git a/internal/resource/container.go b/internal/resource/container.go index 12a4443..ed5b78f 100644 --- a/internal/resource/container.go +++ b/internal/resource/container.go @@ -29,6 +29,9 @@ _ "os/exec" "decl/internal/codec" "decl/internal/data" "decl/internal/folio" + "decl/internal/containerlog" + "bytes" +_ "encoding/base64" ) const ( @@ -43,44 +46,50 @@ type ContainerClient interface { 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"` - 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"` + *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"` + 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"` + 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"` + 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:"-"` } @@ -180,17 +189,22 @@ func (c *Container) Validate() error { } 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 } } @@ -219,6 +233,7 @@ func (c *Container) Notify(m *machine.EventMessage) { 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 @@ -242,6 +257,24 @@ func (c *Container) Notify(m *machine.EventMessage) { } 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 } @@ -275,6 +308,61 @@ 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) @@ -288,6 +376,9 @@ func (c *Container) Create(ctx context.Context) error { Entrypoint: c.Entrypoint, Tty: false, ExposedPorts: portset, + WorkingDir: c.WorkingDir, + AttachStdout: true, + AttachStderr: true, } config.Env = make([]string, numberOfEnvironmentVariables) @@ -319,20 +410,32 @@ func (c *Container) Create(ctx context.Context) error { } c.Id = resp.ID - /* - statusCh, errCh := cli.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning) - select { - case err := <-errCh: - if err != nil { - panic(err) - } - case <-statusCh: - } - */ - 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 } @@ -386,9 +489,6 @@ func (c *Container) Inspect(ctx context.Context, containerID string) error { } c.Common.Path = containerJSON.Path c.Image = containerJSON.Image - if containerJSON.State != nil { - c.ContainerState = *containerJSON.State - } c.Created = containerJSON.Created c.ResolvConfPath = containerJSON.ResolvConfPath c.HostnamePath = containerJSON.HostnamePath @@ -396,11 +496,18 @@ func (c *Container) Inspect(ctx context.Context, containerID string) error { 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 diff --git a/internal/resource/container_network.go b/internal/resource/container_network.go index c11a15c..20348a1 100644 --- a/internal/resource/container_network.go +++ b/internal/resource/container_network.go @@ -98,7 +98,7 @@ func (n *ContainerNetwork) SetResourceMapper(resources data.ResourceMapper) { func (n *ContainerNetwork) Clone() data.Resource { return &ContainerNetwork { - Common: n.Common, + Common: n.Common.Clone(), Id: n.Id, Name: n.Name, apiClient: n.apiClient, diff --git a/internal/resource/container_test.go b/internal/resource/container_test.go index a650a08..c907f47 100644 --- a/internal/resource/container_test.go +++ b/internal/resource/container_test.go @@ -5,20 +5,21 @@ package resource import ( "context" "decl/tests/mocks" - _ "encoding/json" - _ "fmt" +_ "encoding/json" +_ "fmt" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" - _ "io" - _ "net/http" - _ "net/http/httptest" - _ "net/url" - _ "os" - _ "strings" + "io" +_ "net/http" +_ "net/http/httptest" +_ "net/url" +_ "os" + "strings" "testing" + "bytes" ) func TestNewContainerResource(t *testing.T) { @@ -55,6 +56,9 @@ func TestReadContainer(t *testing.T) { go func() { resChan <- res }() return resChan, errChan }, + InjectContainerLogs: func(ctx context.Context, containerID string, options container.LogsOptions) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("done.")), nil + }, } c := NewContainer(m) @@ -87,6 +91,9 @@ func TestCreateContainer(t *testing.T) { go func() { resChan <- res }() return resChan, errChan }, + InjectContainerLogs: func(ctx context.Context, containerID string, options container.LogsOptions) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("done.")), nil + }, } decl := ` @@ -107,3 +114,37 @@ func TestCreateContainer(t *testing.T) { applyDeleteErr := c.Apply() assert.Equal(t, nil, applyDeleteErr) } + +// Detect the ContainerLog header for each entry +func TestContainerLogOutput(t *testing.T) { + logHeader := []byte{0x01, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x05} + logHeader = append(logHeader, []byte(string("done."))...) + logs := bytes.NewReader(logHeader) + + m := &mocks.MockContainerClient{ + InjectContainerCreate: func(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error) { + return container.CreateResponse{ID: "abcdef012", Warnings: []string{}}, nil + }, + InjectContainerStop: func(context.Context, string, container.StopOptions) error { + return nil + }, + InjectContainerRemove: func(context.Context, string, container.RemoveOptions) error { + return nil + }, + InjectContainerWait: func(ctx context.Context, containerID string, condition container.WaitCondition) (<-chan container.WaitResponse, <-chan error) { + var res container.WaitResponse + resChan := make(chan container.WaitResponse) + errChan := make(chan error, 1) + go func() { resChan <- res }() + return resChan, errChan + }, + InjectContainerLogs: func(ctx context.Context, containerID string, options container.LogsOptions) (io.ReadCloser, error) { + return io.NopCloser(logs), nil + }, + } + + c := NewContainer(m) + c.ReadFromContainer(context.Background()) + assert.Equal(t, "done.", c.Stdout) + +} diff --git a/internal/resource/container_volume.go b/internal/resource/container_volume.go new file mode 100644 index 0000000..cbd9265 --- /dev/null +++ b/internal/resource/container_volume.go @@ -0,0 +1,295 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" + "fmt" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/volume" +_ "github.com/docker/docker/api/types/strslice" + "github.com/docker/docker/client" + "gopkg.in/yaml.v3" +_ "log/slog" + "net/url" +_ "os" +_ "os/exec" +_ "strings" + "encoding/json" + "io" + "gitea.rosskeen.house/rosskeen.house/machine" + "decl/internal/codec" + "decl/internal/data" + "decl/internal/folio" + "log/slog" +) + +const ( + ContainerVolumeTypeName TypeName = "container-volume" +) + +type ContainerVolumeClient interface { + ContainerClient + VolumeCreate(ctx context.Context, options volume.CreateOptions) (volume.Volume, error) + VolumeList(ctx context.Context, options volume.ListOptions) (volume.ListResponse, error) + VolumeInspect(ctx context.Context, volumeID string) (volume.Volume, error) + VolumeRemove(ctx context.Context, volumeID string, force bool) (error) +} + +type ContainerVolume struct { + *Common `json:",inline" yaml:",inline"` + stater machine.Stater `json:"-" yaml:"-"` + volume.Volume `json:",inline" yaml:",inline"` + + apiClient ContainerVolumeClient + Resources data.ResourceMapper `json:"-" yaml:"-"` +} + +func init() { + folio.DocumentRegistry.ResourceTypes.Register([]string{"container-volume"}, func(u *url.URL) (n data.Resource) { + n = NewContainerVolume(nil) + if u != nil { + if err := folio.CastParsedURI(u).ConstructResource(n); err != nil { + panic(err) + } + } + return + }) +} + +func NewContainerVolume(containerClientApi ContainerVolumeClient) (cn *ContainerVolume) { + var apiClient ContainerVolumeClient = containerClientApi + if apiClient == nil { + var err error + apiClient, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + panic(err) + } + } + cn = &ContainerVolume{ + apiClient: apiClient, + } + cn.Common = NewCommon(ContainerVolumeTypeName, true) + cn.Common.NormalizePath = cn.NormalizePath + return cn +} + +func (v *ContainerVolume) Init(u data.URIParser) error { + if u == nil { + u = folio.URI(v.URI()).Parse() + } + return v.SetParsedURI(u) +} + +func (v *ContainerVolume) NormalizePath() error { + return nil +} + +func (v *ContainerVolume) SetResourceMapper(resources data.ResourceMapper) { + v.Resources = resources +} + +func (v *ContainerVolume) Clone() data.Resource { + return &ContainerVolume { + Common: v.Common.Clone(), + Volume: v.Volume, + apiClient: v.apiClient, + } +} + +func (v *ContainerVolume) StateMachine() machine.Stater { + if v.stater == nil { + v.stater = StorageMachine(v) + } + return v.stater +} + +func (v *ContainerVolume) Notify(m *machine.EventMessage) { + ctx := context.Background() + slog.Info("Notify()", "ContainerVolume", v, "m", m) + switch m.On { + case machine.ENTERSTATEEVENT: + switch m.Dest { + case "start_read": + if _,readErr := v.Read(ctx); readErr == nil { + if triggerErr := v.StateMachine().Trigger("state_read"); triggerErr == nil { + return + } else { + v.Common.State = "absent" + panic(triggerErr) + } + } else { + v.Common.State = "absent" + panic(readErr) + } + case "start_delete": + if deleteErr := v.Delete(ctx); deleteErr == nil { + if triggerErr := v.StateMachine().Trigger("deleted"); triggerErr == nil { + return + } else { + v.Common.State = "present" + panic(triggerErr) + } + } else { + v.Common.State = "present" + panic(deleteErr) + } + case "start_create": + if e := v.Create(ctx); e == nil { + if triggerErr := v.StateMachine().Trigger("created"); triggerErr == nil { + return + } + } + v.Common.State = "absent" + case "absent": + v.Common.State = "absent" + case "present", "created", "read": + v.Common.State = "present" + } + case machine.EXITSTATEEVENT: + } +} + +func (v *ContainerVolume) URI() string { + return fmt.Sprintf("container-volume://%s", v.Name) +} + +func (v *ContainerVolume) JSON() ([]byte, error) { + return json.Marshal(v) +} + +func (v *ContainerVolume) Validate() error { + return fmt.Errorf("failed") +} + +func (v *ContainerVolume) Apply() error { + ctx := context.Background() + switch v.Common.State { + case "absent": + return v.Delete(ctx) + case "present": + return v.Create(ctx) + } + return nil +} + +func (v *ContainerVolume) Load(docData []byte, f codec.Format) (err error) { + err = f.StringDecoder(string(docData)).Decode(v) + return +} + +func (v *ContainerVolume) LoadReader(r io.ReadCloser, f codec.Format) (err error) { + err = f.Decoder(r).Decode(v) + return +} + +func (v *ContainerVolume) LoadString(docData string, f codec.Format) (err error) { + err = f.StringDecoder(docData).Decode(v) + return +} + +func (n *ContainerVolume) LoadDecl(yamlResourceDeclaration string) error { + return n.LoadString(yamlResourceDeclaration, codec.FormatYaml) +} + +func (v *ContainerVolume) Create(ctx context.Context) (err error) { + var spec volume.ClusterVolumeSpec + if v.ClusterVolume != nil { + spec = v.ClusterVolume.Spec + } + v.Volume, err = v.apiClient.VolumeCreate(ctx, volume.CreateOptions{ + Name: v.Name, + Driver: v.Driver, + DriverOpts: v.Options, + Labels: v.Labels, + ClusterVolumeSpec: &spec, + }) + if err != nil { + panic(err) + } + return nil +} + +func (v *ContainerVolume) Inspect(ctx context.Context, volumeID string) error { + volumeInspect, err := v.apiClient.VolumeInspect(ctx, volumeID) + if client.IsErrNotFound(err) { + v.Common.State = "absent" + } else { + v.Common.State = "present" + v.Volume = volumeInspect + if v.Name == "" { + if volumeInspect.Name[0] == '/' { + v.Name = volumeInspect.Name[1:] + } else { + v.Name = volumeInspect.Name + } + } + } + return nil +} + +func (v *ContainerVolume) Read(ctx context.Context) ([]byte, error) { + var volumeID string + filterArgs := filters.NewArgs() + filterArgs.Add("name", v.Name) + volumes, err := v.apiClient.VolumeList(ctx, volume.ListOptions{ + Filters: filterArgs, + }) + + if err != nil { + return nil, fmt.Errorf("%w: %s %s", err, v.Type(), v.Name) + } + + for _, vol := range volumes.Volumes { + if vol.Name == v.Name { + volumeID = vol.Name + } + } + + if inspectErr := v.Inspect(ctx, volumeID); inspectErr != nil { + return nil, fmt.Errorf("%w: volume %s", inspectErr, volumeID) + } + slog.Info("Read() ", "type", v.Type(), "name", v.Name) + + return yaml.Marshal(v) +} + +func (v *ContainerVolume) Update(ctx context.Context) error { + return v.Create(ctx) +} + +func (v *ContainerVolume) Delete(ctx context.Context) error { + return nil +} + +func (v *ContainerVolume) Type() string { return "container-volume" } + +func (v *ContainerVolume) ResolveId(ctx context.Context) string { + v.Inspect(ctx, v.Name) + return v.Name +/* + volumes, err := n.apiClient.VolumeInspect(ctx, volume.ListOptions{ + + filterArgs := filters.NewArgs() + filterArgs.Add("name", "/"+n.Name) + volumes, err := n.apiClient.VolumeList(ctx, volume.ListOptions{ + All: true, + Filters: filterArgs, + }) + if err != nil { + panic(fmt.Errorf("%w: %s %s", err, n.Type(), n.Name)) + } + + for _, volume := range volumes { + for _, containerName := range volume.Name { + if containerName == n.Name { + if n.Id == "" { + n.Id = container.ID + } + return container.ID + } + } + } + return "" +*/ +} diff --git a/internal/resource/container_volume_test.go b/internal/resource/container_volume_test.go new file mode 100644 index 0000000..eb225fe --- /dev/null +++ b/internal/resource/container_volume_test.go @@ -0,0 +1,50 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" + "decl/tests/mocks" + "github.com/docker/docker/api/types/volume" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNewContainerVolumeResource(t *testing.T) { + c := NewContainerVolume(&mocks.MockContainerClient{}) + assert.NotNil(t, c) +} + +func TestReadContainerVolume(t *testing.T) { + ctx := context.Background() + decl := ` + name: "testcontainervolume" + state: present +` + m := &mocks.MockContainerClient{ + InjectVolumeList: func(ctx context.Context, options volume.ListOptions) (volume.ListResponse, error) { + return volume.ListResponse{ + Volumes: []*volume.Volume{}, + Warnings: []string{}, + }, nil + }, + InjectVolumeInspect: func(ctx context.Context, volumeID string) (volume.Volume, error) { + return volume.Volume{ + Name: "test", + Driver: "local", + Mountpoint: "/src", + }, nil + }, + } + + v := NewContainerVolume(m) + assert.NotNil(t, v) + + e := v.LoadDecl(decl) + assert.Nil(t, e) + assert.Equal(t, "testcontainervolume", v.Name) + + resourceYaml, readContainerVolumeErr := v.Read(ctx) + assert.Equal(t, nil, readContainerVolumeErr) + assert.Greater(t, len(resourceYaml), 0) +} diff --git a/internal/resource/containerlogstreamtype.go b/internal/resource/containerlogstreamtype.go new file mode 100644 index 0000000..174dfb2 --- /dev/null +++ b/internal/resource/containerlogstreamtype.go @@ -0,0 +1,29 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +// Container resource +package resource + +import ( + "fmt" + "errors" +) + +type ContainerLogStreamType byte + +const ( + ContainerLogStreamStdin ContainerLogStreamType = 0x0 + ContainerLogStreamStdout ContainerLogStreamType = 0x1 + ContainerLogStreamStderr ContainerLogStreamType = 0x2 +) + +var ( + ErrContainerLogInvalidStreamType error = errors.New("Invalid container log stream type") +) + +func (s ContainerLogStreamType) Validate() error { + switch s { + case ContainerLogStreamStdin, ContainerLogStreamStdout, ContainerLogStreamStderr: + return nil + } + return fmt.Errorf("%w: %d", ErrContainerLogInvalidStreamType, s) +} diff --git a/internal/resource/containerlogstreamtype_test.go b/internal/resource/containerlogstreamtype_test.go new file mode 100644 index 0000000..a41a984 --- /dev/null +++ b/internal/resource/containerlogstreamtype_test.go @@ -0,0 +1,20 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestContainerLogStreamType(t *testing.T) { + for _, v := range []struct{ expected error; value ContainerLogStreamType } { + { expected: nil, value: 0x0 }, + { expected: nil, value: 0x1 }, + { expected: nil, value: 0x2 }, + { expected: ErrContainerLogInvalidStreamType, value: 0x3 }, + { expected: ErrContainerLogInvalidStreamType, value: 0x4 }, + } { + assert.ErrorIs(t, v.value.Validate(), v.expected) + } +} diff --git a/internal/resource/file.go b/internal/resource/file.go index 454b8a5..92252e1 100644 --- a/internal/resource/file.go +++ b/internal/resource/file.go @@ -202,33 +202,57 @@ func (f *File) Notify(m *machine.EventMessage) { if triggerErr := f.StateMachine().Trigger("state_read"); triggerErr == nil { return } else { - f.Common.State = "absent" - panic(triggerErr) + _ = f.AddError(triggerErr) } } else { - f.Common.State = "absent" - if ! errors.Is(readErr, ErrResourceStateAbsent) { - panic(readErr) + _ = f.AddError(readErr) + if f.IsResourceInconsistent() { + if triggerErr := f.StateMachine().Trigger("read-failed"); triggerErr == nil { + panic(readErr) + } else { + _ = f.AddError(triggerErr) + panic(fmt.Errorf("%w - %w", readErr, triggerErr)) + } } + _ = f.AddError(f.StateMachine().Trigger("notexists")) } case "start_create": - if e := f.Create(ctx); e == nil { + if createErr := f.Create(ctx); createErr == nil { if triggerErr := f.StateMachine().Trigger("created"); triggerErr == nil { return + } else { + _ = f.AddError(triggerErr) } } else { - f.Common.State = "absent" - panic(e) + _ = f.AddError(createErr) + if f.IsResourceInconsistent() { + if triggerErr := f.StateMachine().Trigger("create-failed"); triggerErr == nil { + panic(createErr) + } else { + _ = f.AddError(triggerErr) + panic(fmt.Errorf("%w - %w", createErr, triggerErr)) + } + } + _ = f.StateMachine().Trigger("notexists") + panic(createErr) } case "start_update": if updateErr := f.Update(ctx); updateErr == nil { if triggerErr := f.stater.Trigger("updated"); triggerErr == nil { return } else { - f.Common.State = "absent" + _ = f.AddError(triggerErr) } } else { - f.Common.State = "absent" + _ = f.AddError(updateErr) + if f.IsResourceInconsistent() { + if triggerErr := f.StateMachine().Trigger("update-failed"); triggerErr == nil { + panic(updateErr) + } else { + panic(fmt.Errorf("%w - %w", updateErr, triggerErr)) + } + } + _ = f.StateMachine().Trigger("notexists") panic(updateErr) } case "start_delete": @@ -240,15 +264,21 @@ func (f *File) Notify(m *machine.EventMessage) { panic(triggerErr) } } else { - f.Common.State = "present" + _ = f.StateMachine().Trigger("exists") panic(deleteErr) } + case "inconsistent": + f.Common.State = "inconsistent" case "absent": f.Common.State = "absent" case "present", "created", "read": f.Common.State = "present" } case machine.EXITSTATEEVENT: + switch m.Dest { + case "start_create": + slog.Info("File.Notify - EXITSTATE", "dest", m.Dest, "common.state", f.Common.State) + } } } diff --git a/internal/resource/openpgp_keyring.go b/internal/resource/openpgp_keyring.go new file mode 100644 index 0000000..1cf3e43 --- /dev/null +++ b/internal/resource/openpgp_keyring.go @@ -0,0 +1,427 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" + "errors" + "fmt" + "log/slog" + "gopkg.in/yaml.v3" + "net/url" + "gitea.rosskeen.house/rosskeen.house/machine" + "decl/internal/codec" + "decl/internal/transport" + "decl/internal/data" + "decl/internal/folio" + "crypto" + "encoding/json" + "io" + "strings" + "io/fs" + "os" + "bytes" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +var ( + ErrOpenPGPEncryptionFailure error = errors.New("OpenPGP encryption failure") +) + +const ( + OpenPGPKeyRingTypeName TypeName = "openpgp-keyring" +) + +func init() { + folio.DocumentRegistry.ResourceTypes.Register([]string{"openpgp-keyring"}, func(u *url.URL) (res data.Resource) { + o := NewOpenPGPKeyRing() + if u != nil { + if err := folio.CastParsedURI(u).ConstructResource(o); err != nil { + panic(err) + } + } + return o + }) +} + + +type OpenPGPKeyRing struct { + *Common `json:",inline" yaml:",inline"` + stater machine.Stater `json:"-" yaml:"-"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` + Email string `json:"email,omitempty" yaml:"email,omitempty"` + KeyRing string `json:"keyring,omitempty" yaml:"keyring,omitempty"` + Bits int `json:"bits" yaml:"bits"` + KeyRingRef folio.ResourceReference `json:"keyringref,omitempty" yaml:"keyringref,omitempty"` + + entityList openpgp.EntityList +} + +func NewOpenPGPKeyRing() (o *OpenPGPKeyRing) { + o = &OpenPGPKeyRing { + Common: NewCommon(OpenPGPKeyRingTypeName, false), + Bits: 2048, + } + return +} + +func (o *OpenPGPKeyRing) Init(uri data.URIParser) error { + if uri == nil { + uri = folio.URI(o.URI()).Parse() + } else { +// o.Name = uri.URL().Hostname() + } + return o.SetParsedURI(uri) +} + +func (o *OpenPGPKeyRing) NormalizePath() error { + return nil +} + + +func (o *OpenPGPKeyRing) Validate() (err error) { + var keyringJson []byte + if keyringJson, err = o.JSON(); err == nil { + s := NewSchema(o.Type()) + err = s.Validate(string(keyringJson)) + } + return err +} + +func (o *OpenPGPKeyRing) Config() *packet.Config { + config := &packet.Config{ + RSABits: 2048, + Algorithm: packet.PubKeyAlgoRSA, + DefaultHash: crypto.SHA256, + DefaultCompressionAlgo: packet.CompressionZLIB, + } + return config +} + +func (o *OpenPGPKeyRing) IsEncrypted(index int) (result bool) { + if len(o.entityList) >= index { + result = o.entityList[index].PrivateKey.Encrypted + } + return +} + +func (o *OpenPGPKeyRing) EncryptPrivateKey(entity *openpgp.Entity) error { + passphraseConfig, _ := o.config.GetValue("passphrase") + passphrase := []byte(passphraseConfig.(string)) + if len(passphrase) > 0 { + if encryptErr := entity.PrivateKey.Encrypt(passphrase); encryptErr != nil { + return fmt.Errorf("%w private key: %w", ErrOpenPGPEncryptionFailure, encryptErr) + } + for _, subkey := range entity.Subkeys { + if encryptErr := subkey.PrivateKey.Encrypt(passphrase); encryptErr != nil { + return fmt.Errorf("%w subkey (private key): %w", ErrOpenPGPEncryptionFailure, encryptErr) + } + } + } + return nil +} + +func (o *OpenPGPKeyRing) Create(ctx context.Context) (err error) { + var entity *openpgp.Entity + cfg := o.Config() + entity, err = openpgp.NewEntity(o.Name, o.Comment, o.Email, cfg) + o.entityList = append(o.entityList, entity) + + if entity.PrivateKey == nil { + return fmt.Errorf("Failed creating new private key") + } + + if entity.PrimaryKey == nil { + return fmt.Errorf("Failed creating new public key") + } + + if err = o.EncryptPrivateKey(entity); err != nil { + return + } + + if len(o.KeyRing) == 0 { + var keyringBuffer bytes.Buffer + if publicKeyWriter, err := armor.Encode(&keyringBuffer, openpgp.PublicKeyType, nil); err == nil { + if err = entity.Serialize(publicKeyWriter); err == nil { + + } + publicKeyWriter.Close() + } + keyringBuffer.WriteString("\n") + if privateKeyWriter, err := armor.Encode(&keyringBuffer, openpgp.PrivateKeyType, nil); err == nil { + if err = entity.SerializePrivateWithoutSigning(privateKeyWriter, nil); err == nil { + + } + privateKeyWriter.Close() + } + keyringBuffer.WriteString("\n") + + o.KeyRing = keyringBuffer.String() + } + return +} + +func (o *OpenPGPKeyRing) Clone() data.Resource { + return &OpenPGPKeyRing { + Common: o.Common.Clone(), + Name: o.Name, + Comment: o.Comment, + Email: o.Email, + Bits: o.Bits, + } +} + +func (o *OpenPGPKeyRing) StateMachine() machine.Stater { + if o.stater == nil { + o.stater = StorageMachine(o) + } + return o.stater +} + +func (o *OpenPGPKeyRing) Notify(m *machine.EventMessage) { + ctx := context.Background() + switch m.On { + case machine.ENTERSTATEEVENT: + switch m.Dest { + case "start_stat": + if statErr := o.ReadStat(); statErr == nil { + if triggerErr := o.StateMachine().Trigger("exists"); triggerErr == nil { + return + } + } else { + if triggerErr := o.StateMachine().Trigger("notexists"); triggerErr == nil { + return + } + } + case "start_read": + if _,readErr := o.Read(ctx); readErr == nil { + if triggerErr := o.StateMachine().Trigger("state_read"); triggerErr == nil { + return + } else { + _ = o.AddError(triggerErr) + } + } else { + _ = o.AddError(readErr) + if o.IsResourceInconsistent() { + if triggerErr := o.StateMachine().Trigger("read-failed"); triggerErr == nil { + panic(readErr) + } else { + _ = o.AddError(triggerErr) + panic(fmt.Errorf("%w - %w", readErr, triggerErr)) + } + } + _ = o.AddError(o.StateMachine().Trigger("notexists")) + } + case "start_create": + if createErr := o.Create(ctx); createErr == nil { + if triggerErr := o.StateMachine().Trigger("created"); triggerErr == nil { + return + } else { + _ = o.AddError(triggerErr) + } + } else { + _ = o.AddError(createErr) + if o.IsResourceInconsistent() { + if triggerErr := o.StateMachine().Trigger("create-failed"); triggerErr == nil { + panic(createErr) + } else { + _ = o.AddError(triggerErr) + panic(fmt.Errorf("%w - %w", createErr, triggerErr)) + } + } + _ = o.StateMachine().Trigger("notexists") + panic(createErr) + } + case "start_update": + if updateErr := o.Update(ctx); updateErr == nil { + if triggerErr := o.stater.Trigger("updated"); triggerErr == nil { + return + } else { + _ = o.AddError(triggerErr) + } + } else { + _ = o.AddError(updateErr) + if o.IsResourceInconsistent() { + if triggerErr := o.StateMachine().Trigger("update-failed"); triggerErr == nil { + panic(updateErr) + } else { + panic(fmt.Errorf("%w - %w", updateErr, triggerErr)) + } + } + _ = o.StateMachine().Trigger("notexists") + panic(updateErr) + } + case "start_delete": + if deleteErr := o.Delete(ctx); deleteErr == nil { + if triggerErr := o.StateMachine().Trigger("deleted"); triggerErr == nil { + return + } else { + o.Common.State = "present" + panic(triggerErr) + } + } else { + _ = o.StateMachine().Trigger("exists") + panic(deleteErr) + } + case "inconsistent": + o.Common.State = "inconsistent" + case "absent": + o.Common.State = "absent" + case "present", "created", "read": + o.Common.State = "present" + } + case machine.EXITSTATEEVENT: + switch m.Dest { + case "start_create": + slog.Info("OpenPGP_Entity.Notify - EXITSTATE", "dest", m.Dest, "common.state", o.Common.State) + } + } +} + +func (o *OpenPGPKeyRing) KeyRingRefStat() (info fs.FileInfo, err error) { + if len(o.KeyRingRef) > 0 { + rs, _ := o.ContentReaderStream() + defer rs.Close() + info, err = rs.Stat() + } + return +} + +func (o *OpenPGPKeyRing) FilePath() string { + return o.Common.Path +} + +func (o *OpenPGPKeyRing) JSON() ([]byte, error) { + return json.Marshal(o) +} + +func (o *OpenPGPKeyRing) Apply() error { + ctx := context.Background() + switch o.Common.State { + case "absent": + return o.Delete(ctx) + case "present": + return o.Create(ctx) + } + + return nil +} + +func (o *OpenPGPKeyRing) Load(docData []byte, format codec.Format) (err error) { + err = format.StringDecoder(string(docData)).Decode(o) + return +} + +func (o *OpenPGPKeyRing) LoadReader(r io.ReadCloser, format codec.Format) (err error) { + err = format.Decoder(r).Decode(o) + return +} + +func (o *OpenPGPKeyRing) LoadString(docData string, format codec.Format) (err error) { + err = format.StringDecoder(docData).Decode(o) + return +} + +func (o *OpenPGPKeyRing) LoadDecl(yamlResourceDeclaration string) (err error) { + return o.LoadString(yamlResourceDeclaration, codec.FormatYaml) +} + +func (o *OpenPGPKeyRing) ResolveId(ctx context.Context) string { + if e := o.NormalizePath(); e != nil { + panic(e) + } + return o.Common.Path +} + +func (o *OpenPGPKeyRing) GetContentSourceRef() string { + return string(o.KeyRingRef) +} + +func (o *OpenPGPKeyRing) SetContentSourceRef(uri string) { + o.KeyRingRef = folio.ResourceReference(uri) +} + +func (o *OpenPGPKeyRing) Stat() (info fs.FileInfo, err error) { + return o.KeyRingRefStat() +} + +func (o *OpenPGPKeyRing) Update(ctx context.Context) error { + return o.Create(ctx) +} + +func (o *OpenPGPKeyRing) Delete(ctx context.Context) error { + return os.Remove(o.Common.Path) +} + +func (o *OpenPGPKeyRing) ReadStat() (err error) { + if _, err = o.Stat(); err != nil { + o.Common.State = "absent" + } + return +} + +func (o *OpenPGPKeyRing) Read(ctx context.Context) (yamlData []byte, err error) { + var keyringReader io.ReadCloser + statErr := o.ReadStat() + if statErr != nil { + return nil, fmt.Errorf("%w - %w: %s", ErrResourceStateAbsent, statErr, o.Path) + } + + if keyringReader, err = o.GetContent(nil); err == nil { + if krData, krErr := io.ReadAll(keyringReader); krErr == nil { + o.KeyRing = string(krData) + o.entityList, err = openpgp.ReadArmoredKeyRing(strings.NewReader(o.KeyRing)) + } else { + err = krErr + } + } + + o.Common.State = "present" + return yaml.Marshal(o) +} + +func (o *OpenPGPKeyRing) GetContent(w io.Writer) (contentReader io.ReadCloser, err error) { + contentReader, err = o.readThru() + if w != nil { + copyBuffer := make([]byte, 32 * 1024) + _, writeErr := io.CopyBuffer(w, contentReader, copyBuffer) + if writeErr != nil { + return nil, fmt.Errorf("OpenPGPKeyRing.GetContent(): CopyBuffer failed %v %v: %w", w, contentReader, writeErr) + } + return nil, nil + } + return +} + +func (o *OpenPGPKeyRing) readThru() (contentReader io.ReadCloser, err error) { + if o.KeyRingRef.IsEmpty() { + if len(o.KeyRing) != 0 { + contentReader = io.NopCloser(strings.NewReader(o.KeyRing)) + } + } else { + contentReader, err = o.KeyRingRef.Lookup(nil).ContentReaderStream() + contentReader.(*transport.Reader).SetGzip(false) + } + return +} + +func (o *OpenPGPKeyRing) URI() string { return string(o.Common.URI()) } + +func (o *OpenPGPKeyRing) Type() string { return "openpgp-keyring" } + +func (o *OpenPGPKeyRing) ContentReaderStream() (*transport.Reader, error) { + if len(o.KeyRing) == 0 && ! o.KeyRingRef.IsEmpty() { + return o.KeyRingRef.Lookup(nil).ContentReaderStream() + } + return nil, fmt.Errorf("Cannot provide transport reader for string content") +} + +func (o *OpenPGPKeyRing) ContentWriterStream() (*transport.Writer, error) { + if len(o.KeyRing) == 0 && ! o.KeyRingRef.IsEmpty() { + return o.KeyRingRef.Lookup(nil).ContentWriterStream() + } + return nil, fmt.Errorf("Cannot provide transport writer for string content") +} diff --git a/internal/resource/openpgp_keyring_test.go b/internal/resource/openpgp_keyring_test.go new file mode 100644 index 0000000..2f6bcf6 --- /dev/null +++ b/internal/resource/openpgp_keyring_test.go @@ -0,0 +1,108 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" + "github.com/stretchr/testify/assert" + "testing" + "fmt" + "decl/internal/data" +) + +func TestNewOpenPGPKeyRingResource(t *testing.T) { + assert.NotNil(t, NewOpenPGPKeyRing()) +} + +func TestCreateKeyRing(t *testing.T) { + ctx := context.Background() + + declarationAttributes := ` + name: TestUser1 + comment: TestUser1 + email: testuser@rosskeen.house +` + + testKeyRing := NewOpenPGPKeyRing() + e := testKeyRing.LoadDecl(declarationAttributes) + assert.Nil(t, e) + + testKeyRing.UseConfig(MockConfigValueGetter(func(key string) (any, error) { + switch key { + case "passphrase": + return "foo", nil + } + return nil, fmt.Errorf("%w: %s", data.ErrUnknownConfigurationKey, key) + })) + + + err := testKeyRing.Create(ctx) + assert.Nil(t, err) + + assert.Greater(t, len(testKeyRing.entityList), 0) + assert.Contains(t, testKeyRing.entityList[0].Identities, "TestUser1 (TestUser1) ") + assert.Contains(t, testKeyRing.KeyRing, "-----END PGP PUBLIC KEY BLOCK-----") + assert.Contains(t, testKeyRing.KeyRing, "-----END PGP PRIVATE KEY BLOCK-----") + + assert.True(t, testKeyRing.IsEncrypted(0)) +} + +func TestReadKeyRing(t *testing.T) { + ctx := context.Background() + + declarationAttributes := ` + keyring: |- + -----BEGIN PGP PUBLIC KEY BLOCK----- + + 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 + +aAQq8u60fWheEIG+xaV3T01zrNRUo6I7xu5kA== + =yFbn + -----END PGP PUBLIC KEY BLOCK----- +` + + testKeyRing := NewOpenPGPKeyRing() + e := testKeyRing.LoadDecl(declarationAttributes) + assert.Nil(t, e) + y, err := testKeyRing.Read(ctx) + assert.Nil(t, err) + assert.NotNil(t, y) + + assert.Greater(t, len(testKeyRing.entityList), 0) + assert.Contains(t, testKeyRing.entityList[0].Identities, "TestUser (TestUser) ") + +} diff --git a/internal/resource/openpgp_signature.go b/internal/resource/openpgp_signature.go new file mode 100644 index 0000000..8a3978f --- /dev/null +++ b/internal/resource/openpgp_signature.go @@ -0,0 +1,365 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" + "errors" + "fmt" + "log/slog" + "gopkg.in/yaml.v3" + "net/url" + "gitea.rosskeen.house/rosskeen.house/machine" + "decl/internal/codec" + "decl/internal/ext" + "decl/internal/transport" + "decl/internal/data" + "decl/internal/folio" + "crypto" + "encoding/json" + "io" + "io/fs" + "bytes" + "os" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +var ( + ErrSignatureWriterFailed error = errors.New("Failed creating signature writer") + ErrArmoredWriterFailed error = errors.New("Failed to create armored writer") +) + +const ( + OpenPGPSignatureTypeName TypeName = "openpgp-signature" +) + +func init() { + folio.DocumentRegistry.ResourceTypes.Register([]string{"openpgp-signature"}, func(u *url.URL) (res data.Resource) { + o := NewOpenPGPSignature() + if u != nil { + if err := folio.CastParsedURI(u).ConstructResource(o); err != nil { + panic(err) + } + } + return o + }) +} + +type OpenPGPSignature struct { + *Common `json:",inline" yaml:",inline"` + stater machine.Stater `json:"-" yaml:"-"` + Signature string `json:"signature,omitempty" yaml:"signature,omitempty"` + KeyRingRef folio.ResourceReference `json:"keyringref,omitempty" yaml:"keyringref,omitempty"` + SourceRef folio.ResourceReference `json:"soureref,omitempty" yaml:"sourceref,omitempty"` + SignatureRef folio.ResourceReference `json:"signatureref,omitempty" yaml:"signatureref,omitempty"` + + message *openpgp.MessageDetails + entityList openpgp.EntityList +} + + +func NewOpenPGPSignature() *OpenPGPSignature { + o := &OpenPGPSignature { + Common: NewCommon(OpenPGPSignatureTypeName, false), + } + return o +} + +func (o *OpenPGPSignature) Type() string { return "openpgp-signature" } + +func (o *OpenPGPSignature) Init(uri data.URIParser) error { + if uri == nil { + uri = folio.URI(o.URI()).Parse() + } else { +// o.Name = uri.URL().Hostname() + } + return o.SetParsedURI(uri) +} + +func (o *OpenPGPSignature) NormalizePath() error { + return nil +} + +func (o *OpenPGPSignature) StateMachine() machine.Stater { + if o.stater == nil { + o.stater = StorageMachine(o) + } + return o.stater +} + +func (o *OpenPGPSignature) URI() string { + return string(o.Common.URI()) +} + +func (o *OpenPGPSignature) SigningEntity() (err error) { + if o.KeyRingRef.IsEmpty() { + var keyringConfig any + if keyringConfig, err = o.config.GetValue("keyring"); err == nil { + o.entityList = keyringConfig.(openpgp.EntityList) + } + } else { + ringFileStream, _ := o.KeyRingRef.Lookup(o.Resources).ContentReaderStream() + defer ringFileStream.Close() + o.entityList, err = openpgp.ReadArmoredKeyRing(ringFileStream) + } + return +} + + +func (o *OpenPGPSignature) Sign(message io.Reader, w io.Writer) (err error) { + var writer io.WriteCloser + entity := o.entityList[0] + + if writer, err = openpgp.Sign(w, entity, nil, o.Config()); err == nil { + defer writer.Close() + _, err = io.Copy(writer, message) + } else { + err = fmt.Errorf("%w: %w", ErrSignatureWriterFailed, err) + } + return +} + +func (o *OpenPGPSignature) Create(ctx context.Context) (err error) { + if err = o.SigningEntity(); err == nil { + var sourceReadStream io.ReadCloser + sourceReadStream, err = o.SourceRef.Lookup(o.Resources).ContentReaderStream() + var signatureStream, armoredWriter io.WriteCloser + + if o.SignatureRef.IsEmpty() { + var signatureContent bytes.Buffer + signatureStream = ext.WriteNopCloser(&signatureContent) + defer func() { o.Signature = signatureContent.String() }() + } else { + signatureStream, _ = o.SignatureRef.Lookup(o.Resources).ContentWriterStream() + } + + if armoredWriter, err = armor.Encode(signatureStream, openpgp.SignatureType, nil); err != nil { + err = fmt.Errorf("%w: %w", ErrArmoredWriterFailed, err) + } + defer armoredWriter.Close() + + err = o.Sign(sourceReadStream, armoredWriter) + } + return +} + +func (o *OpenPGPSignature) Validate() (err error) { + var signatureJson []byte + if signatureJson, err = o.JSON(); err == nil { + s := NewSchema(o.Type()) + err = s.Validate(string(signatureJson)) + } + return err +} + +func (o *OpenPGPSignature) Config() *packet.Config { + config := &packet.Config{ + RSABits: 2048, + Algorithm: packet.PubKeyAlgoRSA, + DefaultHash: crypto.SHA256, + DefaultCompressionAlgo: packet.CompressionZLIB, + } + return config +} + +func (o *OpenPGPSignature) Clone() data.Resource { + return &OpenPGPSignature { + Common: o.Common.Clone(), + KeyRingRef: o.KeyRingRef, + SourceRef: o.SourceRef, + SignatureRef: o.SignatureRef, + } +} + +func (o *OpenPGPSignature) Notify(m *machine.EventMessage) { + ctx := context.Background() + switch m.On { + case machine.ENTERSTATEEVENT: + switch m.Dest { + case "start_stat": + if statErr := o.ReadStat(); statErr == nil { + if triggerErr := o.StateMachine().Trigger("exists"); triggerErr == nil { + return + } + } else { + if triggerErr := o.StateMachine().Trigger("notexists"); triggerErr == nil { + return + } + } + case "start_read": + if _,readErr := o.Read(ctx); readErr == nil { + if triggerErr := o.StateMachine().Trigger("state_read"); triggerErr == nil { + return + } else { + _ = o.AddError(triggerErr) + } + } else { + _ = o.AddError(readErr) + if o.IsResourceInconsistent() { + if triggerErr := o.StateMachine().Trigger("read-failed"); triggerErr == nil { + panic(readErr) + } else { + _ = o.AddError(triggerErr) + panic(fmt.Errorf("%w - %w", readErr, triggerErr)) + } + } + _ = o.AddError(o.StateMachine().Trigger("notexists")) + } + case "start_create": + if createErr := o.Create(ctx); createErr == nil { + if triggerErr := o.StateMachine().Trigger("created"); triggerErr == nil { + return + } else { + _ = o.AddError(triggerErr) + } + } else { + _ = o.AddError(createErr) + if o.IsResourceInconsistent() { + if triggerErr := o.StateMachine().Trigger("create-failed"); triggerErr == nil { + panic(createErr) + } else { + _ = o.AddError(triggerErr) + panic(fmt.Errorf("%w - %w", createErr, triggerErr)) + } + } + _ = o.StateMachine().Trigger("notexists") + panic(createErr) + } + case "start_update": + if updateErr := o.Update(ctx); updateErr == nil { + if triggerErr := o.stater.Trigger("updated"); triggerErr == nil { + return + } else { + _ = o.AddError(triggerErr) + } + } else { + _ = o.AddError(updateErr) + if o.IsResourceInconsistent() { + if triggerErr := o.StateMachine().Trigger("update-failed"); triggerErr == nil { + panic(updateErr) + } else { + panic(fmt.Errorf("%w - %w", updateErr, triggerErr)) + } + } + _ = o.StateMachine().Trigger("notexists") + panic(updateErr) + } + case "start_delete": + if deleteErr := o.Delete(ctx); deleteErr == nil { + if triggerErr := o.StateMachine().Trigger("deleted"); triggerErr == nil { + return + } else { + o.Common.State = "present" + panic(triggerErr) + } + } else { + _ = o.StateMachine().Trigger("exists") + panic(deleteErr) + } + case "inconsistent": + o.Common.State = "inconsistent" + case "absent": + o.Common.State = "absent" + case "present", "created", "read": + o.Common.State = "present" + } + case machine.EXITSTATEEVENT: + switch m.Dest { + case "start_create": + slog.Info("OpenPGPSignature.Notify - EXITSTATE", "dest", m.Dest, "common.state", o.Common.State) + } + } +} + +func (o *OpenPGPSignature) FilePath() string { + return o.Common.Path +} + +func (o *OpenPGPSignature) JSON() ([]byte, error) { + return json.Marshal(o) +} + +func (o *OpenPGPSignature) Apply() error { + ctx := context.Background() + switch o.Common.State { + case "absent": + return o.Delete(ctx) + case "present": + return o.Create(ctx) + } + + return nil +} + +func (o *OpenPGPSignature) Load(docData []byte, format codec.Format) (err error) { + err = format.StringDecoder(string(docData)).Decode(o) + return +} + +func (o *OpenPGPSignature) LoadReader(r io.ReadCloser, format codec.Format) (err error) { + err = format.Decoder(r).Decode(o) + return +} + +func (o *OpenPGPSignature) LoadString(docData string, format codec.Format) (err error) { + err = format.StringDecoder(docData).Decode(o) + return +} + +func (o *OpenPGPSignature) LoadDecl(yamlResourceDeclaration string) (err error) { + return o.LoadString(yamlResourceDeclaration, codec.FormatYaml) +} + +func (o *OpenPGPSignature) ResolveId(ctx context.Context) string { + if e := o.NormalizePath(); e != nil { + panic(e) + } + return o.Common.Path +} + +func (o *OpenPGPSignature) GetContentSourceRef() string { + return string(o.SignatureRef) +} + +func (o *OpenPGPSignature) SetContentSourceRef(uri string) { + o.SignatureRef = folio.ResourceReference(uri) +} + +func (o *OpenPGPSignature) SignatureRefStat() (info fs.FileInfo, err error) { + err = fmt.Errorf("%w: %s", ErrResourceStateAbsent, o.SignatureRef) + if len(o.SignatureRef) > 0 { + rs, _ := o.ContentReaderStream() + defer rs.Close() + return rs.Stat() + } + return +} + +func (o *OpenPGPSignature) Stat() (info fs.FileInfo, err error) { + return o.SignatureRefStat() +} + +func (o *OpenPGPSignature) ReadStat() (err error) { + _, err = o.SignatureRefStat() + return err +} + +func (o *OpenPGPSignature) Update(ctx context.Context) error { + return o.Create(ctx) +} + +func (o *OpenPGPSignature) Delete(ctx context.Context) error { + return os.Remove(o.Common.Path) +} + +func (o *OpenPGPSignature) Read(ctx context.Context) ([]byte, error) { + return yaml.Marshal(o) +} + +func (o *OpenPGPSignature) ContentReaderStream() (*transport.Reader, error) { + return nil, nil +} + + diff --git a/internal/resource/openpgp_signature_test.go b/internal/resource/openpgp_signature_test.go new file mode 100644 index 0000000..0594952 --- /dev/null +++ b/internal/resource/openpgp_signature_test.go @@ -0,0 +1,142 @@ +// Copyright 2024 Matthew Rich . All rights reserved. + +package resource + +import ( + "context" + "github.com/stretchr/testify/assert" + "testing" + "fmt" + "decl/internal/data" + "decl/internal/folio" + "decl/internal/codec" + "log" +) + +func NewTestUserKeys() (data.ResourceMapper, folio.URI) { + uri := "openpgp-keyring://TestUser1/TestUser1/testuser@rosskeen.house" + keyRingDecl := folio.NewDeclaration() + keyRingDecl.NewResource(&uri) + + ctx := context.Background() + declarationAttributes := fmt.Sprintf(` + name: TestUser1 + comment: TestUser1 + email: testuser@rosskeen.house + keyringref: file://%s/keyring.asc +`, string(TempDir)) + + testKeyRing := keyRingDecl.Resource() + if e := testKeyRing.LoadString(declarationAttributes, codec.FormatYaml); e != nil { + log.Fatal(e) + } + + testKeyRing.UseConfig(MockConfigValueGetter(func(key string) (any, error) { + switch key { + case "passphrase": + return "foo", nil + } + return nil, fmt.Errorf("%w: %s", data.ErrUnknownConfigurationKey, key) + })) + + if err := testKeyRing.Create(ctx); err != nil { + log.Fatal(err) + } + return TestResourceMapper(func(key string) (data.Declaration, bool) { + return keyRingDecl, true + }), folio.URI(uri) + +} + +func TestNewOpenPGPSignatureResource(t *testing.T) { + assert.NotNil(t, NewOpenPGPSignature()) +} + +func TestCreateSignature(t *testing.T) { + ctx := context.Background() + + m, keyRingUri := NewTestUserKeys() + + assert.Nil(t, TempDir.CreateFile("test.txt", "test data")) + + declarationAttributes := fmt.Sprintf(` + keyringref: %s + sourceref: file://%s/test.txt +`, string(keyRingUri), string(TempDir)) + + testSignature := NewOpenPGPSignature() + testSignature.Resources = m + e := testSignature.LoadDecl(declarationAttributes) + assert.Nil(t, e) + + err := testSignature.Create(ctx) + assert.Nil(t, err) +/* + assert.Greater(t, len(testSignature.entityList), 0) + assert.Contains(t, testSignature.entityList[0].Identities, "TestUser1 (TestUser1) ") + assert.Contains(t, testSignature.Signature, "-----END PGP PUBLIC KEY BLOCK-----") + assert.Contains(t, testSignature.Signature, "-----END PGP PRIVATE KEY BLOCK-----") +*/ +} + +/* +func TestReadSignature(t *testing.T) { + ctx := context.Background() + + declarationAttributes := ` + signature: |- + -----BEGIN PGP PUBLIC KEY BLOCK----- + + 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 + +aAQq8u60fWheEIG+xaV3T01zrNRUo6I7xu5kA== + =yFbn + -----END PGP PUBLIC KEY BLOCK----- +` + + testSignature := NewOpenPGPSignature() + e := testSignature.LoadDecl(declarationAttributes) + assert.Nil(t, e) + y, err := testSignature.Read(ctx) + assert.Nil(t, err) + assert.NotNil(t, y) + + assert.Greater(t, len(testSignature.entityList), 0) + assert.Contains(t, testSignature.entityList[0].Identities, "TestUser (TestUser) ") + +} +*/ diff --git a/internal/resource/resource.go b/internal/resource/resource.go index 6096271..653f0e2 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -40,12 +40,12 @@ func ResourceConstructor(res data.Resource, uri data.URIParser) (err error) { return } -func StorageMachine(sub machine.Subscriber) machine.Stater { -// start_destroy -> absent -> start_create -> present -> start_destroy +// Common resource states +func ResourceMachine(sub machine.Subscriber) machine.Stater { stater := machine.New("unknown") - stater.AddStates("unkonwn", "absent", "start_create", "present", "start_delete", "start_read", "start_update", "start_stat") + stater.AddStates("unkonwn", "inconsistent", "absent", "start_create", "present", "start_delete", "start_read", "start_update", "start_stat", "created") - stater.AddTransition("create", machine.States("unknown", "absent"), "start_create") + stater.AddTransition("create", machine.States("unknown", "inconsistent", "absent"), "start_create") if e := stater.AddSubscription("create", sub); e != nil { return nil } @@ -54,7 +54,8 @@ func StorageMachine(sub machine.Subscriber) machine.Stater { if e := stater.AddSubscription("created", sub); e != nil { return nil } - stater.AddTransition("exists", machine.States("unknown", "absent", "start_stat"), "present") + + stater.AddTransition("exists", machine.States("unknown", "inconsistent", "absent", "start_stat"), "present") if e := stater.AddSubscription("exists", sub); e != nil { return nil } @@ -64,6 +65,24 @@ func StorageMachine(sub machine.Subscriber) machine.Stater { return nil } + stater.AddTransition("update-failed", machine.States("start_update"), "inconsistent") + if e := stater.AddSubscription("update-failed", sub); e != nil { + return nil + } + + stater.AddTransition("create-failed", machine.States("start_create"), "inconsistent") + if e := stater.AddSubscription("create-failed", sub); e != nil { + return nil + } + + return stater +} + +func StorageMachine(sub machine.Subscriber) machine.Stater { +// start_destroy -> absent -> start_create -> present -> start_destroy + + stater := ResourceMachine(sub) + stater.AddTransition("read", machine.States("*"), "start_read") if e := stater.AddSubscription("read", sub); e != nil { return nil @@ -98,24 +117,12 @@ func StorageMachine(sub machine.Subscriber) machine.Stater { func ProcessMachine(sub machine.Subscriber) machine.Stater { // "enum": [ "created", "restarting", "running", "paused", "exited", "dead" ] - stater := machine.New("unknown") - stater.AddStates("unkonwn", "absent", "start_create", "present", "created", "restarting", "running", "paused", "exited", "dead", "start_delete", "start_read", "start_update") - stater.AddTransition("create", machine.States("unknown", "absent"), "start_create") - if e := stater.AddSubscription("create", sub); e != nil { - return nil - } - stater.AddTransition("created", machine.States("start_create"), "present") - if e := stater.AddSubscription("created", sub); e != nil { - return nil - } - stater.AddTransition("exists", machine.States("unknown", "absent"), "present") - if e := stater.AddSubscription("exists", sub); e != nil { - return nil - } - stater.AddTransition("notexists", machine.States("*"), "absent") - if e := stater.AddSubscription("notexists", sub); e != nil { - return nil - } + + stater := ResourceMachine(sub) + + stater.AddStates("restarting", "running", "paused", "exited", "dead") + + stater.AddTransition("read", machine.States("*"), "start_read") if e := stater.AddSubscription("read", sub); e != nil { return nil diff --git a/internal/resource/schemas/document.schema.json b/internal/resource/schemas/document.schema.json index 1f45ea8..6dbd353 100644 --- a/internal/resource/schemas/document.schema.json +++ b/internal/resource/schemas/document.schema.json @@ -30,6 +30,7 @@ { "$ref": "http-declaration.schema.json" }, { "$ref": "iptable-declaration.schema.json" }, { "$ref": "network-route-declaration.schema.json" }, + { "$ref": "openpgp-keyring-declaration.schema.json" }, { "$ref": "package-declaration.schema.json" }, { "$ref": "pki-declaration.schema.json" }, { "$ref": "user-declaration.schema.json" } diff --git a/internal/resource/schemas/openpgp-keyring-declaration.schema.json b/internal/resource/schemas/openpgp-keyring-declaration.schema.json new file mode 100644 index 0000000..2915e21 --- /dev/null +++ b/internal/resource/schemas/openpgp-keyring-declaration.schema.json @@ -0,0 +1,21 @@ +{ + "$id": "openpgp-keyring-declaration.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "openpgp-keyring-declaration", + "type": "object", + "required": [ "type", "attributes" ], + "properties": { + "type": { + "type": "string", + "description": "Resource type name.", + "enum": [ "file" ] + }, + "config": { + "type": "string", + "description": "Config name" + }, + "attributes": { + "$ref": "openpgp-keyring.schema.json" + } + } +} diff --git a/internal/resource/schemas/openpgp-keyring.schema.json b/internal/resource/schemas/openpgp-keyring.schema.json new file mode 100644 index 0000000..630ceb0 --- /dev/null +++ b/internal/resource/schemas/openpgp-keyring.schema.json @@ -0,0 +1,17 @@ +{ + "$id": "openpgp-keyring.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "openpgp-keyring", + "type": "object", + "required": [ ], + "properties": { + "armored": { + "type": "string", + "description": "ASCII-armored PGP keyring" + }, + "sourceref": { + "type": "string", + "description": "file content source uri" + } + } +} diff --git a/internal/resource/user.go b/internal/resource/user.go index 35ee6e7..b650fa3 100644 --- a/internal/resource/user.go +++ b/internal/resource/user.go @@ -325,14 +325,21 @@ func (u *User) ReadGroups() (err error) { return } -func (u *User) Update(ctx context.Context) (error) { - _, err := u.UpdateCommand.Execute(u) +func (u *User) Update(ctx context.Context) (err error) { + switch u.UserType { + case UserTypeBusyBox: + _, err = u.CreateCommand.Execute(u) + if err != nil { + return err + } + } + _, err = u.UpdateCommand.Execute(u) if err != nil { return err } - _,e := u.Read(ctx) - return e + _, err = u.Read(ctx) + return } func (u *User) Delete(ctx context.Context) (error) { @@ -489,11 +496,16 @@ func NewUserShadowCreateCommand() *command.Command { func NewUserBusyBoxCreateCommand() *command.Command { c := command.NewCommand() c.Path = "adduser" + c.Split = false c.Args = []command.CommandArg{ - command.CommandArg("{{ if .UID }}-u {{ .UID }}{{ end }}"), - command.CommandArg("{{ if .Gecos }}-g {{ .Gecos }}{{ end }}"), - command.CommandArg("{{ if .Group }}-G {{ .Group }}{{ end }}"), - command.CommandArg("{{ if .Home }}-h {{ .Home }}{{ end }}"), + command.CommandArg("{{ if .UID }}-u{{ end }}"), + command.CommandArg("{{ if .UID }}{{ .UID }}{{ end }}"), + command.CommandArg("{{ if .Gecos }}-g{{ end }}"), + command.CommandArg("{{ if .Gecos }}{{ .Gecos }}{{ end }}"), + command.CommandArg("{{ if .Group }}-G{{ end }}"), + command.CommandArg("{{ if .Group }}{{ .Group }}{{ end }}"), + command.CommandArg("{{ if .Home }}-h{{ end }}"), + command.CommandArg("{{ if .Home }}{{ .Home }}{{ end }}"), command.CommandArg("{{ if not .CreateHome }}-H{{ end }}"), command.CommandArg("-D"), command.CommandArg("{{ .Name }}"), @@ -544,8 +556,11 @@ func NewUserBusyBoxUpdateCommand() *command.Command { c.StdinAvailable = true c.Input = command.CommandInput("{{ if .Groups }}{{ range .Groups }}{{ . }}\n{{ end }}{{ end }}") c.Args = []command.CommandArg{ + command.CommandArg("-r"), + command.CommandArg("-IGROUP"), command.CommandArg("adduser"), command.CommandArg("{{ .Name }}"), + command.CommandArg("GROUP"), } c.Extractor = func(out []byte, target any) error { return nil