add support for remote command execution
Some checks failed
Declarative Tests / test (push) Waiting to run
Lint / golangci-lint (push) Has been cancelled

This commit is contained in:
Matthew Rich 2024-11-10 10:24:06 -08:00
parent 94f3998fcd
commit 07808c62fd
18 changed files with 1790 additions and 95 deletions

View File

@ -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
}

View File

@ -29,6 +29,9 @@ _ "os/exec"
"decl/internal/codec"
"decl/internal/data"
"decl/internal/folio"
"decl/internal/containerlog"
"bytes"
_ "encoding/base64"
)
const (
@ -43,6 +46,7 @@ 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
}
@ -52,6 +56,7 @@ type Container struct {
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"`
@ -81,6 +86,10 @@ type Container struct {
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,8 +410,17 @@ func (c *Container) Create(ctx context.Context) error {
}
c.Id = resp.ID
/*
statusCh, errCh := cli.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
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 {
@ -328,11 +428,14 @@ func (c *Container) Create(ctx context.Context) error {
}
case <-statusCh:
}
*/
if startErr := c.apiClient.ContainerStart(ctx, c.Id, container.StartOptions{}); startErr != nil {
return startErr
}
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

View File

@ -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,

View File

@ -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)
}

View File

@ -0,0 +1,295 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. 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 ""
*/
}

View File

@ -0,0 +1,50 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. 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)
}

View File

@ -0,0 +1,29 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. 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)
}

View File

@ -0,0 +1,20 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. 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)
}
}

View File

@ -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) {
_ = 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)
}
}
}

View File

@ -0,0 +1,427 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. 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")
}

View File

@ -0,0 +1,108 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. 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) <testuser@rosskeen.house>")
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) <matthewrich.conf@gmail.com>")
}

View File

@ -0,0 +1,365 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. 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
}

View File

@ -0,0 +1,142 @@
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. 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) <testuser@rosskeen.house>")
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) <matthewrich.conf@gmail.com>")
}
*/

View File

@ -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

View File

@ -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" }

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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