// Copyright 2024 Matthew Rich . 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 }