Skip to content

Commit 4121393

Browse files
Link task runner API implementation
Implemented the `snowblock.TaskRunner` API interface to handle `link` tasks from the original Python implementation (1). References: (1) https://github.com/arcticicestudio/snowsaw/blob/3e3840824bf6f3d5cc09573b9505737473c7ed95/README.md#link Epic GH-33 Resolves GH-74
1 parent 145a4c3 commit 4121393

File tree

4 files changed

+353
-1
lines changed

4 files changed

+353
-1
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/magefile/mage v1.8.0
99
github.com/mattn/go-colorable v0.1.2 // indirect
1010
github.com/mitchellh/go-homedir v1.1.0
11+
github.com/mitchellh/mapstructure v1.1.2
1112
github.com/spf13/cobra v0.0.5
1213
gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22
1314
)

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE
2222
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
2323
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
2424
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
25+
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
2526
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
2627
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
2728
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

pkg/config/constants.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/arcticicestudio/snowsaw/pkg/api/snowblock"
1616
"github.com/arcticicestudio/snowsaw/pkg/config/source/file"
1717
"github.com/arcticicestudio/snowsaw/pkg/snowblock/task"
18+
"github.com/arcticicestudio/snowsaw/pkg/snowblock/task/link"
1819
)
1920

2021
const (
@@ -38,7 +39,9 @@ var (
3839
// AppConfigPaths is the default paths the application will search for configuration files.
3940
AppConfigPaths []*file.File
4041

41-
availableTaskRunner []snowblock.TaskRunner
42+
availableTaskRunner = []snowblock.TaskRunner{
43+
&link.Link{},
44+
}
4245

4346
// BuildDateTime is the date and time this application was build.
4447
BuildDateTime string

pkg/snowblock/task/link/link.go

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
// Copyright (C) 2017-present Arctic Ice Studio <[email protected]>
2+
// Copyright (C) 2017-present Sven Greb <[email protected]>
3+
//
4+
// Project: snowsaw
5+
// Repository: https://github.com/arcticicestudio/snowsaw
6+
// License: MIT
7+
8+
// Author: Arctic Ice Studio <[email protected]>
9+
// Author: Sven Greb <[email protected]>
10+
// Since: 0.4.0
11+
12+
// Package link provides a task runner implementation to create symbolic links for files and directories.
13+
package link
14+
15+
import (
16+
"errors"
17+
"fmt"
18+
"os"
19+
"path/filepath"
20+
"strings"
21+
22+
"github.com/fatih/color"
23+
"github.com/mitchellh/mapstructure"
24+
25+
"github.com/arcticicestudio/snowsaw/pkg/api/snowblock"
26+
"github.com/arcticicestudio/snowsaw/pkg/prt"
27+
"github.com/arcticicestudio/snowsaw/pkg/util/filesystem"
28+
)
29+
30+
const (
31+
// DefaultHostName is the name for host mappings that will apply to all host.
32+
// To prevent possible collisions with actual host names, it is a single minus character.
33+
// As defined in the specification this is not a valid hostname since the name should not start or end with a minus.
34+
// See "RFC 1123" and https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_hostnames for more details about
35+
// restrictions and valid names.
36+
DefaultHostName = "-"
37+
)
38+
39+
// Link is a task runner to create symbolic links for files and directories.
40+
type Link struct {
41+
config *config
42+
destAbsPath string
43+
destPath string
44+
snowblockAbsPath string
45+
srcAbsPath string
46+
}
47+
48+
type config struct {
49+
Create bool `json:"create" yaml:"create"`
50+
Force bool `json:"force" yaml:"force"`
51+
Hosts map[string]string `json:"hosts,flow" yaml:"hosts,flow"`
52+
Path string `json:"path" yaml:"path"`
53+
Relative bool `json:"relative" yaml:"relative"`
54+
Relink bool `json:"relink" yaml:"relink"`
55+
}
56+
57+
// GetTaskName returns the name of the task this runner can process.
58+
func (l Link) GetTaskName() string {
59+
return "link"
60+
}
61+
62+
// Run processes a task using the given task instructions.
63+
// The snowblockAbsPath parameter is the absolute path of the snowblock used as contextual information.
64+
func (l *Link) Run(configuration snowblock.TaskConfiguration, snowblockAbsPath string) error {
65+
l.snowblockAbsPath = snowblockAbsPath
66+
67+
// Try to convert given task configurations...
68+
configMap, ok := configuration.(map[string]interface{})
69+
if !ok {
70+
prt.Debugf("invalid link configuration type: %s", color.RedString("%T", configuration))
71+
return errors.New("malformed link configuration")
72+
}
73+
74+
// ...and handle the possible types.
75+
for destPath, configData := range configMap {
76+
l.destAbsPath = ""
77+
l.srcAbsPath = ""
78+
79+
switch configType := configData.(type) {
80+
// Handle JSON `null` value configurations used to omit duplicate definitions when the source path equals the
81+
// destination path.
82+
// Uses the base name of the destination path and trims a leading dot character if present.
83+
case nil:
84+
sourceBaseName := strings.TrimPrefix(filepath.Base(destPath), ".")
85+
l.config = &config{Path: sourceBaseName}
86+
l.destPath = destPath
87+
if execErr := l.execute(); execErr != nil {
88+
return execErr
89+
}
90+
91+
// Handle JSON `object` configurations used to define more link options.
92+
// Uses the base name of the destination path with leading dot character trimmed if path is not specified.
93+
case map[string]interface{}:
94+
c := new(config)
95+
if err := mapstructure.Decode(configType, &c); err != nil {
96+
return err
97+
}
98+
l.destPath = destPath
99+
if c.Path == "" {
100+
c.Path = strings.TrimPrefix(filepath.Base(destPath), ".")
101+
}
102+
l.config = c
103+
if execErr := l.execute(); execErr != nil {
104+
return execErr
105+
}
106+
107+
// Handle JSON `string` configurations used to only specify the source path.
108+
case string:
109+
l.config = &config{Path: configType}
110+
l.destPath = destPath
111+
if execErr := l.execute(); execErr != nil {
112+
return execErr
113+
}
114+
115+
// Reject invalid or unsupported JSON data structures.
116+
default:
117+
prt.Debugf("unsupported destination type: %s", color.RedString("%T", configType))
118+
return fmt.Errorf("unsupported link configuration: %s", color.CyanString(destPath))
119+
}
120+
}
121+
122+
return nil
123+
}
124+
125+
func (l *Link) execute() error {
126+
// Check if the current and/or default host is listed in the target mapping, otherwise stop processing.
127+
isTargetHost, hostCheckErr := l.isTargetHost()
128+
if hostCheckErr != nil {
129+
return hostCheckErr
130+
}
131+
if !isTargetHost {
132+
return nil
133+
}
134+
135+
// Dissolve the source to an absolute path.
136+
srcAbsPath, srcToAbsPathErr := filepath.Abs(filepath.Join(l.snowblockAbsPath, l.config.Path))
137+
if srcToAbsPathErr != nil {
138+
return srcToAbsPathErr
139+
}
140+
l.srcAbsPath = srcAbsPath
141+
142+
// Fail fast if the source node does not exist.
143+
if sourceNodeExistsErr := l.checkSourceNode(); sourceNodeExistsErr != nil {
144+
return sourceNodeExistsErr
145+
}
146+
147+
// Expand the destination path to dissolve environment variables and special characters like tilde...
148+
expDestPath, pathExpandErr := filesystem.ExpandPath(l.destPath)
149+
if pathExpandErr != nil {
150+
return pathExpandErr
151+
}
152+
153+
if !filepath.IsAbs(expDestPath) {
154+
l.destAbsPath = filepath.Join(l.snowblockAbsPath, expDestPath)
155+
} else {
156+
l.destAbsPath = expDestPath
157+
}
158+
159+
destNodeExists, nodeExistErr := filesystem.NodeExists(l.destAbsPath)
160+
if nodeExistErr != nil {
161+
return nodeExistErr
162+
}
163+
// Check if the destination node already exists,...
164+
if destNodeExists {
165+
isSymlink, symlinkCheckErr := filesystem.IsSymlink(l.destAbsPath)
166+
if symlinkCheckErr != nil {
167+
return symlinkCheckErr
168+
}
169+
// ...evaluate if it is a symbolic link,...
170+
if isSymlink {
171+
symlinkDest, symlinkReadErr := os.Readlink(l.destAbsPath)
172+
if symlinkReadErr != nil {
173+
return symlinkReadErr
174+
}
175+
symlinkDestAbs, symlinkDestAbsErr := filepath.Abs(symlinkDest)
176+
if symlinkDestAbsErr != nil {
177+
return symlinkDestAbsErr
178+
}
179+
180+
// ...and continue with processing when running in relinking mode,...
181+
if l.config.Relink {
182+
prt.Warnf("%s already existing symbolic link: %s",
183+
color.YellowString("Relinking"), color.CyanString(l.destAbsPath))
184+
if removeErr := os.Remove(l.destAbsPath); removeErr != nil {
185+
return removeErr
186+
}
187+
if parentDirErr := l.handleParentDirStructure(); parentDirErr != nil {
188+
return parentDirErr
189+
}
190+
if symlinkCreationError := l.createSymbolicLink(); symlinkCreationError != nil {
191+
return symlinkCreationError
192+
}
193+
return nil
194+
}
195+
196+
// ...or stop processing when it already links to the correct destination,...
197+
if symlinkDestAbs == l.srcAbsPath {
198+
prt.Infof("Skipped already existing link: %s", color.CyanString(l.destAbsPath))
199+
return nil
200+
}
201+
202+
// ...otherwise only if force linking is enabled.
203+
if l.config.Force {
204+
prt.Warnf("%s of already existing symbolic link: %s",
205+
color.YellowString("Forced linking"), color.CyanString(l.destAbsPath))
206+
if removeErr := os.Remove(l.destAbsPath); removeErr != nil {
207+
return removeErr
208+
}
209+
if parentDirErr := l.handleParentDirStructure(); parentDirErr != nil {
210+
return parentDirErr
211+
}
212+
if symlinkCreationError := l.createSymbolicLink(); symlinkCreationError != nil {
213+
return symlinkCreationError
214+
}
215+
return nil
216+
}
217+
218+
return fmt.Errorf("symbolic link already exists: %s ← %s", symlinkDest, l.destAbsPath)
219+
}
220+
221+
// Always process the task in force mode when the destination is an already existing file or directory,...
222+
if l.config.Force {
223+
prt.Warnf("%s of already existing symbolic link: %s",
224+
color.YellowString("Forced linking"), color.CyanString(l.destAbsPath))
225+
if removeErr := os.Remove(l.destAbsPath); removeErr != nil {
226+
return removeErr
227+
}
228+
if parentDirErr := l.handleParentDirStructure(); parentDirErr != nil {
229+
return parentDirErr
230+
}
231+
if symlinkCreationError := l.createSymbolicLink(); symlinkCreationError != nil {
232+
return symlinkCreationError
233+
}
234+
return nil
235+
}
236+
237+
return fmt.Errorf("file or directory already exists: %s", l.config.Path)
238+
}
239+
240+
// ...otherwise only when all previous conditions are not met.
241+
if parentDirErr := l.handleParentDirStructure(); parentDirErr != nil {
242+
return parentDirErr
243+
}
244+
if symlinkCreateErr := l.createSymbolicLink(); symlinkCreateErr != nil {
245+
return symlinkCreateErr
246+
}
247+
248+
return nil
249+
}
250+
251+
// checkSourceNode checks if the source node at the given path exists, otherwise returns the corresponding error.
252+
func (l *Link) checkSourceNode() error {
253+
sourceNodeExists, err := filesystem.NodeExists(l.srcAbsPath)
254+
if err != nil {
255+
return err
256+
}
257+
if !sourceNodeExists {
258+
return fmt.Errorf("no such file or directory: %s", l.config.Path)
259+
}
260+
261+
return nil
262+
}
263+
264+
// createSymbolicLink creates the symbolic link based on the value of the task option that allows to use relative
265+
// instead of absolute paths.
266+
// If any error occurs it will be returned, otherwise returns nil.
267+
func (l *Link) createSymbolicLink() error {
268+
if l.config.Relative {
269+
srcRelPath, srcRelPathErr := filepath.Rel(filepath.Dir(l.destAbsPath), l.srcAbsPath)
270+
if srcRelPathErr != nil {
271+
return fmt.Errorf("could not dissolve path of source relative to destination directory: %v", srcRelPathErr)
272+
}
273+
274+
if relSymlinkErr := os.Symlink(srcRelPath, l.destAbsPath); relSymlinkErr != nil {
275+
return relSymlinkErr
276+
}
277+
prt.Infof("Created relative symbolic link: %s → %s", color.CyanString(l.srcAbsPath), color.BlueString(l.srcAbsPath))
278+
return nil
279+
}
280+
281+
if symlinkErr := os.Symlink(l.srcAbsPath, l.destAbsPath); symlinkErr != nil {
282+
return symlinkErr
283+
}
284+
prt.Infof("Created symbolic link: %s → %s", color.BlueString(l.destAbsPath), color.CyanString(l.srcAbsPath))
285+
return nil
286+
}
287+
288+
// handleParentDirStructure checks if the required parent directory structure for the symbolic links exists,
289+
// otherwise creates it if the corresponding task option has been specified.
290+
// If any error occurs it will be returned, otherwise returns nil.
291+
func (l *Link) handleParentDirStructure() error {
292+
destParentDirs := filepath.Dir(l.destAbsPath)
293+
destParentDirsExist, nodeExistErr := filesystem.DirExists(destParentDirs)
294+
if nodeExistErr != nil {
295+
return nodeExistErr
296+
}
297+
if !destParentDirsExist {
298+
if l.config.Create {
299+
if mkdirErr := os.MkdirAll(destParentDirs, os.ModePerm); mkdirErr != nil {
300+
return mkdirErr
301+
}
302+
prt.Debugf("Created parent directory structure: %s", destParentDirs)
303+
} else {
304+
return fmt.Errorf("no such directory: %s", destParentDirs)
305+
}
306+
}
307+
308+
return nil
309+
}
310+
311+
// isTargetHost checks if the current and/or default host is listed in the target mapping.
312+
// It returns the host specific source path, otherwise if an error occurs an empty string along with the error.
313+
func (l *Link) isTargetHost() (bool, error) {
314+
if len(l.config.Hosts) > 0 {
315+
hostname, err := os.Hostname()
316+
if err != nil {
317+
return false, fmt.Errorf("failed to determine hostname: %v", err)
318+
}
319+
sourcePath, isTargetHost := l.config.Hosts[hostname]
320+
sourcePathDefaultHost, isDefaultTargetHost := l.config.Hosts[DefaultHostName]
321+
if !isTargetHost && !isDefaultTargetHost {
322+
prt.Debugf("Skipped host specific link not matching current host %s: %s",
323+
color.BlueString(hostname), color.CyanString(l.destPath))
324+
return false, nil
325+
}
326+
327+
// Use the default target host if specified...
328+
if isDefaultTargetHost {
329+
prt.Debugf("Found host mapping for default target: %s", color.CyanString(sourcePathDefaultHost))
330+
l.config.Path = sourcePathDefaultHost
331+
}
332+
// ...and override when exact host name has also been specified.
333+
if isTargetHost {
334+
prt.Debugf("Using source path for exact host name match %s: %s",
335+
color.BlueString(hostname), color.CyanString(sourcePath))
336+
l.config.Path = sourcePath
337+
}
338+
339+
return true, nil
340+
}
341+
342+
if l.config.Path != "" {
343+
return true, nil
344+
}
345+
346+
return false, nil
347+
}

0 commit comments

Comments
 (0)