458 lines
10 KiB
Go
458 lines
10 KiB
Go
// Copyright 2024 Matthew Rich <matthewrich.conf@gmail.com>. All rights reserved.
|
|
|
|
package resource
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"gopkg.in/yaml.v3"
|
|
"io"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
"crypto/sha256"
|
|
"gitea.rosskeen.house/rosskeen.house/machine"
|
|
"decl/internal/codec"
|
|
)
|
|
|
|
type FileType string
|
|
|
|
const (
|
|
RegularFile FileType = "regular"
|
|
DirectoryFile FileType = "directory"
|
|
BlockDeviceFile FileType = "block"
|
|
CharacterDeviceFile FileType = "char"
|
|
NamedPipeFile FileType = "pipe"
|
|
SymbolicLinkFile FileType = "symlink"
|
|
SocketFile FileType = "socket"
|
|
)
|
|
|
|
var ErrInvalidResourceURI error = errors.New("Invalid resource URI")
|
|
var ErrInvalidFileInfo error = errors.New("Invalid FileInfo")
|
|
var ErrInvalidFileMode error = errors.New("Invalid Mode")
|
|
var ErrInvalidFileOwner error = errors.New("Unknown User")
|
|
var ErrInvalidFileGroup error = errors.New("Unknown Group")
|
|
|
|
func init() {
|
|
ResourceTypes.Register("file", func(u *url.URL) Resource {
|
|
f := NewFile()
|
|
f.Path = filepath.Join(u.Hostname(), u.Path)
|
|
return f
|
|
})
|
|
}
|
|
|
|
// Manage the state of file system objects
|
|
type File struct {
|
|
stater machine.Stater `json:"-" yaml:"-"`
|
|
normalizePath bool `json:"-" yaml:"-"`
|
|
Path string `json:"path" yaml:"path"`
|
|
Owner string `json:"owner" yaml:"owner"`
|
|
Group string `json:"group" yaml:"group"`
|
|
Mode string `json:"mode" yaml:"mode"`
|
|
|
|
Atime time.Time `json:"atime,omitempty" yaml:"atime,omitempty"`
|
|
Ctime time.Time `json:"ctime,omitempty" yaml:"ctime,omitempty"`
|
|
Mtime time.Time `json:"mtime,omitempty" yaml:"mtime,omitempty"`
|
|
|
|
Content string `json:"content,omitempty" yaml:"content,omitempty"`
|
|
Sha256 string `json:"sha256,omitempty" yaml:"sha256,omitempty"`
|
|
Size int64 `json:"size,omitempty" yaml:"size,omitempty"`
|
|
Target string `json:"target,omitempty" yaml:"target,omitempty"`
|
|
FileType FileType `json:"filetype" yaml:"filetype"`
|
|
State string `json:"state,omitempty" yaml:"state,omitempty"`
|
|
}
|
|
|
|
type ResourceFileInfo struct {
|
|
resource *File
|
|
}
|
|
|
|
func NewFile() *File {
|
|
currentUser, _ := user.Current()
|
|
group, _ := user.LookupGroupId(currentUser.Gid)
|
|
f := &File{ normalizePath: false, Owner: currentUser.Username, Group: group.Name, Mode: "0644", FileType: RegularFile}
|
|
slog.Info("NewFile()", "file", f)
|
|
return f
|
|
}
|
|
|
|
func NewNormalizedFile() *File {
|
|
f := NewFile()
|
|
f.normalizePath = true
|
|
return f
|
|
}
|
|
|
|
func (f *File) Clone() Resource {
|
|
return &File {
|
|
normalizePath: f.normalizePath,
|
|
Path: f.Path,
|
|
Owner: f.Owner,
|
|
Group: f.Group,
|
|
Mode: f.Mode,
|
|
Atime: f.Atime,
|
|
Ctime: f.Ctime,
|
|
Mtime: f.Mtime,
|
|
Content: f.Content,
|
|
Sha256: f.Sha256,
|
|
Size: f.Size,
|
|
Target: f.Target,
|
|
FileType: f.FileType,
|
|
State: f.State,
|
|
}
|
|
}
|
|
|
|
func (f *File) StateMachine() machine.Stater {
|
|
if f.stater == nil {
|
|
f.stater = StorageMachine(f)
|
|
}
|
|
return f.stater
|
|
}
|
|
|
|
func (f *File) Notify(m *machine.EventMessage) {
|
|
ctx := context.Background()
|
|
slog.Info("Notify()", "file", f, "m", m)
|
|
switch m.On {
|
|
case machine.ENTERSTATEEVENT:
|
|
switch m.Dest {
|
|
case "start_read":
|
|
if _,readErr := f.Read(ctx); readErr == nil {
|
|
if triggerErr := f.StateMachine().Trigger("state_read"); triggerErr == nil {
|
|
return
|
|
} else {
|
|
f.State = "absent"
|
|
panic(triggerErr)
|
|
}
|
|
} else {
|
|
f.State = "absent"
|
|
panic(readErr)
|
|
}
|
|
case "start_create":
|
|
if e := f.Create(ctx); e == nil {
|
|
if triggerErr := f.StateMachine().Trigger("created"); triggerErr == nil {
|
|
return
|
|
}
|
|
}
|
|
f.State = "absent"
|
|
case "start_delete":
|
|
if deleteErr := f.Delete(ctx); deleteErr == nil {
|
|
if triggerErr := f.StateMachine().Trigger("deleted"); triggerErr == nil {
|
|
return
|
|
} else {
|
|
f.State = "present"
|
|
panic(triggerErr)
|
|
}
|
|
} else {
|
|
f.State = "present"
|
|
panic(deleteErr)
|
|
}
|
|
case "absent":
|
|
f.State = "absent"
|
|
case "present", "created", "read":
|
|
f.State = "present"
|
|
}
|
|
case machine.EXITSTATEEVENT:
|
|
}
|
|
}
|
|
|
|
func (f *File) URI() string {
|
|
return fmt.Sprintf("file://%s", f.Path)
|
|
}
|
|
|
|
func (f *File) SetURI(uri string) error {
|
|
resourceUri, e := url.Parse(uri)
|
|
if e == nil {
|
|
if resourceUri.Scheme == "file" {
|
|
f.Path = filepath.Join(resourceUri.Hostname(), resourceUri.RequestURI())
|
|
if err := f.NormalizePath(); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
e = fmt.Errorf("%w: %s is not a file", ErrInvalidResourceURI, uri)
|
|
}
|
|
}
|
|
return e
|
|
}
|
|
|
|
func (f *File) Validate() error {
|
|
return fmt.Errorf("failed")
|
|
}
|
|
|
|
func (f *File) Apply() error {
|
|
ctx := context.Background()
|
|
switch f.State {
|
|
case "absent":
|
|
return f.Delete(ctx)
|
|
case "present":
|
|
return f.Create(ctx)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *File) LoadDecl(yamlResourceDeclaration string) (err error) {
|
|
d := codec.NewYAMLStringDecoder(yamlResourceDeclaration)
|
|
err = d.Decode(f)
|
|
if err == nil {
|
|
f.UpdateContentAttributes()
|
|
}
|
|
return
|
|
}
|
|
|
|
func (f *File) ResolveId(ctx context.Context) string {
|
|
if e := f.NormalizePath(); e != nil {
|
|
panic(e)
|
|
}
|
|
return f.Path
|
|
}
|
|
|
|
func (f *File) NormalizePath() error {
|
|
if f.normalizePath {
|
|
filePath, fileAbsErr := filepath.Abs(f.Path)
|
|
if fileAbsErr == nil {
|
|
f.Path = filePath
|
|
}
|
|
return fileAbsErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *File) FileInfo() fs.FileInfo {
|
|
return &ResourceFileInfo{ resource: f }
|
|
}
|
|
|
|
func (f *ResourceFileInfo) Name() string {
|
|
// return filepath.Base(f.resource.Path)
|
|
return f.resource.Path
|
|
}
|
|
|
|
func (f *ResourceFileInfo) Size() int64 {
|
|
return f.resource.Size
|
|
}
|
|
|
|
func (f *ResourceFileInfo) Mode() (mode os.FileMode) {
|
|
if fileMode, fileModeErr := strconv.ParseInt(f.resource.Mode, 8, 64); fileModeErr == nil {
|
|
mode |= os.FileMode(fileMode)
|
|
} else {
|
|
panic(fileModeErr)
|
|
}
|
|
mode |= f.resource.FileType.GetMode()
|
|
return
|
|
}
|
|
|
|
func (f *ResourceFileInfo) ModTime() time.Time {
|
|
return f.resource.Mtime
|
|
}
|
|
|
|
func (f *ResourceFileInfo) IsDir() bool {
|
|
return f.resource.FileType == DirectoryFile
|
|
}
|
|
|
|
func (f *ResourceFileInfo) Sys() any {
|
|
return nil
|
|
}
|
|
|
|
func (f *File) Create(ctx context.Context) error {
|
|
uid, uidErr := LookupUID(f.Owner)
|
|
if uidErr != nil {
|
|
return fmt.Errorf("%w: unkwnon user %d", ErrInvalidFileOwner, uid)
|
|
}
|
|
gid, gidErr := LookupGID(f.Group)
|
|
if gidErr != nil {
|
|
return gidErr
|
|
}
|
|
mode, modeErr := strconv.ParseInt(f.Mode, 8, 64)
|
|
if modeErr != nil {
|
|
return fmt.Errorf("%w: invalid mode %d", ErrInvalidFileMode, mode)
|
|
}
|
|
//e := os.Stat(f.path)
|
|
//if os.IsNotExist(e) {
|
|
switch f.FileType {
|
|
case SymbolicLinkFile:
|
|
linkErr := os.Symlink(f.Target, f.Path)
|
|
if linkErr != nil {
|
|
return linkErr
|
|
}
|
|
case DirectoryFile:
|
|
if mkdirErr := os.MkdirAll(f.Path, os.FileMode(mode)); mkdirErr != nil {
|
|
return mkdirErr
|
|
}
|
|
default:
|
|
fallthrough
|
|
case RegularFile:
|
|
createdFile, e := os.Create(f.Path)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
defer createdFile.Close()
|
|
if chmodErr := createdFile.Chmod(os.FileMode(mode)); chmodErr != nil {
|
|
return chmodErr
|
|
}
|
|
_, writeErr := createdFile.Write([]byte(f.Content))
|
|
if writeErr != nil {
|
|
return writeErr
|
|
}
|
|
if !f.Mtime.IsZero() && !f.Atime.IsZero() {
|
|
if chtimesErr := os.Chtimes(f.Path, f.Atime, f.Mtime); chtimesErr != nil {
|
|
return chtimesErr
|
|
}
|
|
}
|
|
}
|
|
if chownErr := os.Chown(f.Path, uid, gid); chownErr != nil {
|
|
return chownErr
|
|
}
|
|
f.State = "present"
|
|
return nil
|
|
}
|
|
|
|
func (f *File) Delete(ctx context.Context) error {
|
|
return os.Remove(f.Path)
|
|
}
|
|
|
|
func (f *File) UpdateContentAttributes() {
|
|
f.Size = int64(len(f.Content))
|
|
f.Sha256 = fmt.Sprintf("%x", sha256.Sum256([]byte(f.Content)))
|
|
}
|
|
|
|
func (f *File) UpdateAttributesFromFileInfo(info os.FileInfo) error {
|
|
if info != nil {
|
|
f.Mtime = info.ModTime()
|
|
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
f.Atime = time.Unix(int64(stat.Atim.Sec), int64(stat.Atim.Nsec))
|
|
f.Ctime = time.Unix(int64(stat.Ctim.Sec), int64(stat.Ctim.Nsec))
|
|
|
|
userId := strconv.Itoa(int(stat.Uid))
|
|
groupId := strconv.Itoa(int(stat.Gid))
|
|
fileUser, userErr := user.LookupId(userId)
|
|
if userErr != nil { //UnknownUserIdError
|
|
//panic(userErr)
|
|
f.Owner = userId
|
|
} else {
|
|
f.Owner = fileUser.Username
|
|
}
|
|
fileGroup, groupErr := user.LookupGroupId(groupId)
|
|
if groupErr != nil {
|
|
//panic(groupErr)
|
|
f.Group = groupId
|
|
} else {
|
|
f.Group = fileGroup.Name
|
|
}
|
|
}
|
|
f.Size = info.Size()
|
|
f.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
|
|
f.FileType.SetMode(info.Mode())
|
|
return nil
|
|
}
|
|
return ErrInvalidFileInfo
|
|
}
|
|
|
|
func (f *File) ReadStat() error {
|
|
info, e := os.Lstat(f.Path)
|
|
if e != nil {
|
|
f.State = "absent"
|
|
return e
|
|
}
|
|
|
|
return f.UpdateAttributesFromFileInfo(info)
|
|
}
|
|
|
|
func (f *File) Read(ctx context.Context) ([]byte, error) {
|
|
if normalizePathErr := f.NormalizePath(); normalizePathErr != nil {
|
|
return nil, normalizePathErr
|
|
}
|
|
|
|
statErr := f.ReadStat()
|
|
if statErr != nil {
|
|
return nil, statErr
|
|
}
|
|
|
|
switch f.FileType {
|
|
case RegularFile:
|
|
file, fileErr := os.Open(f.Path)
|
|
if fileErr != nil {
|
|
panic(fileErr)
|
|
}
|
|
|
|
fileContent, ioErr := io.ReadAll(file)
|
|
if ioErr != nil {
|
|
panic(ioErr)
|
|
}
|
|
f.Content = string(fileContent)
|
|
f.Sha256 = fmt.Sprintf("%x", sha256.Sum256(fileContent))
|
|
case SymbolicLinkFile:
|
|
linkTarget, pathErr := os.Readlink(f.Path)
|
|
if pathErr != nil {
|
|
return nil, pathErr
|
|
}
|
|
f.Target = linkTarget
|
|
}
|
|
f.State = "present"
|
|
return yaml.Marshal(f)
|
|
}
|
|
|
|
func (f *File) SetContent(r io.Reader) error {
|
|
fileContent, ioErr := io.ReadAll(r)
|
|
f.Content = string(fileContent)
|
|
f.Sha256 = fmt.Sprintf("%x", sha256.Sum256(fileContent))
|
|
return ioErr
|
|
}
|
|
|
|
func (f *File) Type() string { return "file" }
|
|
|
|
func (f *FileType) UnmarshalYAML(value *yaml.Node) error {
|
|
var s string
|
|
if err := value.Decode(&s); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch s {
|
|
case string(RegularFile), string(DirectoryFile), string(BlockDeviceFile), string(CharacterDeviceFile), string(NamedPipeFile), string(SymbolicLinkFile), string(SocketFile):
|
|
*f = FileType(s)
|
|
return nil
|
|
default:
|
|
return errors.New("invalid FileType value")
|
|
}
|
|
}
|
|
|
|
func (f *FileType) SetMode(mode os.FileMode) {
|
|
switch true {
|
|
case mode.IsRegular():
|
|
*f = RegularFile
|
|
case mode.IsDir():
|
|
*f = DirectoryFile
|
|
case mode&os.ModeSymlink != 0:
|
|
*f = SymbolicLinkFile
|
|
case mode&os.ModeNamedPipe != 0:
|
|
*f = NamedPipeFile
|
|
case mode&os.ModeSocket != 0:
|
|
*f = SocketFile
|
|
case mode&os.ModeCharDevice != 0:
|
|
*f = CharacterDeviceFile
|
|
case mode&os.ModeDevice != 0:
|
|
*f = BlockDeviceFile
|
|
}
|
|
}
|
|
|
|
func (f *FileType) GetMode() (mode os.FileMode) {
|
|
switch *f {
|
|
case RegularFile:
|
|
case DirectoryFile:
|
|
mode |= os.ModeDir
|
|
case SymbolicLinkFile:
|
|
mode |= os.ModeSymlink
|
|
case NamedPipeFile:
|
|
mode |= os.ModeNamedPipe
|
|
case SocketFile:
|
|
mode |= os.ModeSocket
|
|
case CharacterDeviceFile:
|
|
mode |= os.ModeCharDevice
|
|
case BlockDeviceFile:
|
|
mode |= os.ModeDevice
|
|
}
|
|
return
|
|
}
|