From 3dc5aae8195e26d7bf9af1c1c3a6f191d35e7356 Mon Sep 17 00:00:00 2001 From: Victor Moene Date: Thu, 12 Jun 2025 16:46:25 +0200 Subject: [PATCH] Added symlinks promise type Ticket: CFE-4541 Signed-off-by: Victor Moene --- promise-types/symlinks/README.md | 53 +++++++++++++ promise-types/symlinks/enable.cf | 6 ++ promise-types/symlinks/example.cf | 15 ++++ promise-types/symlinks/symlinks.py | 117 +++++++++++++++++++++++++++++ promise-types/symlinks/test.cf | 116 ++++++++++++++++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 promise-types/symlinks/README.md create mode 100644 promise-types/symlinks/enable.cf create mode 100644 promise-types/symlinks/example.cf create mode 100644 promise-types/symlinks/symlinks.py create mode 100644 promise-types/symlinks/test.cf diff --git a/promise-types/symlinks/README.md b/promise-types/symlinks/README.md new file mode 100644 index 0000000..66cb3c7 --- /dev/null +++ b/promise-types/symlinks/README.md @@ -0,0 +1,53 @@ +The `symlink` promise type enables concise policy for symbolic links. + +## Attributes + +| Name | Type | Description | Default | +|---------------|---------------|-----------------------------------------------------------|---------------| +| `file` | `string` | Path to file. Cannot be used together with `directory`. | - | +| `directory` | `string` | Path to directory. Cannot be used together with `file`. | - | + +## Examples + +To create a symlink to the directory `/tmp/my-dir` with the name `/tmp/my-link`, we can do: + +```cfengine3 +bundle agent main +{ + symlinks: + "/tmp/my-link" + directory => "/tmp/my-dir"; +} +``` + +In similar fashion, to create a symlink to the file `/tmp/my-dir` with the name `/tmp/my-link`, we can do: + +```cfengine3 +bundle agent main +{ + symlinks: + "/tmp/my-link" + file => "/tmp/my-file"; +} +``` + +If the path to the file/directory given in the promise is not an absolute, doesn't exist or its type doesn't correspond with the promise's attribute ("file" or "directory"), then the promise will fail. + +Trying to symlink to a file/directory where the link name is the same as an existing file/directory will also make the promise fail. + +Already exisiting symlinks with incorrect target will be corrected according to the policy. + + +## Authors + +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! + +## Contribute + +Feel free to open pull requests to expand this documentation, add features, or fix problems. +You can also pick up an existing task or file an issue in [our bug tracker](https://northerntech.atlassian.net/). + +## License + +This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text. diff --git a/promise-types/symlinks/enable.cf b/promise-types/symlinks/enable.cf new file mode 100644 index 0000000..39b15f0 --- /dev/null +++ b/promise-types/symlinks/enable.cf @@ -0,0 +1,6 @@ +promise agent symlinks +# @brief Define symlinks promise type +{ + path => "$(sys.workdir)/modules/promises/symlinks.py"; + interpreter => "/usr/bin/python3"; +} diff --git a/promise-types/symlinks/example.cf b/promise-types/symlinks/example.cf new file mode 100644 index 0000000..0fafc2b --- /dev/null +++ b/promise-types/symlinks/example.cf @@ -0,0 +1,15 @@ +promise agent symlinks +# @brief Define symlinks promise type +{ + path => "$(sys.workdir)/modules/promises/symlinks.py"; + interpreter => "/usr/bin/python3"; +} + +bundle agent main +{ + symlinks: + "/tmp/myfilelink" + file => "tmp/myfile"; + "/tmp/mydirlink" + directory => "tmp/mydirectory"; +} diff --git a/promise-types/symlinks/symlinks.py b/promise-types/symlinks/symlinks.py new file mode 100644 index 0000000..2803c1c --- /dev/null +++ b/promise-types/symlinks/symlinks.py @@ -0,0 +1,117 @@ +import os +from cfengine import PromiseModule, ValidationError, Result + + +class SymlinksPromiseTypeModule(PromiseModule): + + def __init__(self, **kwargs): + super(SymlinksPromiseTypeModule, self).__init__( + name="symlinks_promise_module", + version="0.0.1", + **kwargs, + ) + + def is_absolute_dir(v): + if not os.path.isabs(v): + raise ValidationError("must be an absolute path, not '{v}'".format(v=v)) + if not os.path.exists(v): + raise ValidationError("directory must exists") + if not os.path.isdir(v): + raise ValidationError("must be a dir") + + def is_absolute_file(v): + if not os.path.isabs(v): + raise ValidationError("must be an absolute path, not '{v}'".format(v=v)) + if not os.path.exists(v): + raise ValidationError("file must exists") + if not os.path.isfile(v): + raise ValidationError("must be a file") + + self.add_attribute("directory", str, validator=is_absolute_dir) + self.add_attribute("file", str, validator=is_absolute_file) + + def validate_promise(self, promiser, attributes, metadata): + model = self.create_attribute_object(promiser, attributes) + + if not model.file and not model.directory: + raise ValidationError("missing 'file' or 'directory' attribute") + + if model.file and model.directory: + raise ValidationError("must specify either 'file' or 'directory', not both") + + def evaluate_promise(self, promiser, attributes, metadata): + model = self.create_attribute_object(promiser, attributes) + link_target = model.file if model.file else model.directory + + try: + os.symlink(link_target, promiser, target_is_directory=bool(model.directory)) + self.log_info("Created symlink '{}' -> '{}'".format(promiser, link_target)) + return Result.REPAIRED + except FileExistsError: + + if not os.path.islink(promiser): + self.log_error("Symlink '{}' is already a path".format(promiser)) + return Result.NOT_KEPT + + if os.path.realpath(promiser) != link_target: + self.log_warning( + "Symlink '{}' already exists but has wrong target '{}'".format( + promiser, os.path.realpath(promiser) + ) + ) + try: + os.unlink(promiser) + except FileNotFoundError: + self.log_error( + "'{}' is already unlinked from its old target".format(promiser) + ) + return Result.NOT_KEPT + except Exception: + self.log_error( + "'{}' has wrong target but couldn't be unlinked: {}".format( + promiser, e + ) + ) + return Result.NOT_KEPT + try: + os.symlink( + link_target, promiser, target_is_directory=bool(model.directory) + ) + except FileExistsError: + self.log_error( + "Couldn't symlink '{}' to '{}'. A symlink already exists".format( + link_target, promiser + ) + ) + return Result.NOT_KEPT + except FileNotFoundError: + self.log_error("'{}' doesn't exist".format(link_target)) + return Result.NOT_KEPT + except Exception as e: + self.log_error( + "Couldn't symlink '{}' to '{}': {}".format( + link_target, promiser, e + ) + ) + return Result.NOT_KEPT + + self.log_info( + "Corrected symlink '{}' -> '{}'".format(promiser, link_target) + ) + return Result.REPAIRED + + return Result.KEPT + + except FileNotFoundError: + self.log_error("'{}' doesn't exist".format(promiser)) + return Result.NOT_KEPT + + except Exception as e: + self.log_error( + "Couldn't symlink '{}' to '{}': {}".format(link_target, promiser, e) + ) + return Result.NOT_KEPT + + +if __name__ == "__main__": + SymlinksPromiseTypeModule().start() diff --git a/promise-types/symlinks/test.cf b/promise-types/symlinks/test.cf new file mode 100644 index 0000000..468ac0d --- /dev/null +++ b/promise-types/symlinks/test.cf @@ -0,0 +1,116 @@ +body common control +{ + inputs => { "$(sys.libdir)/stdlib.cf" }; + version => "1.0"; + bundlesequence => { "init", "test", "check", "cleanup"}; +} + +####################################################### + +bundle agent init +{ + files: + "/tmp/my-file" + create => "true"; + "/tmp/my-dir/." + create => "true"; + "/tmp/other-dir/." + create => "true"; + "/tmp/replaced-link" + link_from => ln_s("/tmp/other-dir"); + "/tmp/already-existing-link" + link_from => ln_s("/tmp/other-dir"); +} + +####################################################### + +promise agent symlinks +{ + path => "$(this.promise_dirname)/symlinks.py"; + interpreter => "/usr/bin/python3"; +} + +body classes outcome(arg) +{ + promise_kept => { "$(arg)_kept" }; + promise_repaired => { "$(arg)_repaired" }; +} + +bundle agent test +{ + meta: + "description" -> { "CFE-4541" } + string => "Test the symlinks promise module"; + + symlinks: + "/tmp/file-link" + file => "/tmp/my-file", + classes => outcome("created_file"); + "/tmp/dir-link" + directory => "/tmp/my-dir", + classes => outcome("created_dir"); + "/tmp/replaced-link" + directory => "/tmp/my-dir", + classes => outcome("corrected"); + "/tmp/already-existing-link" + directory => "/tmp/other-dir", + classes => outcome("didnothing"); + +} + +####################################################### + +bundle agent check +{ + + vars: + "my_file_stat" + string => filestat("/tmp/file-link", "linktarget"); + "my_dir_stat" + string => filestat("/tmp/dir-link", "linktarget"); + "replaced_link_stat" + string => filestat("/tmp/replaced-link", "linktarget"); + "already_existing_link_stat" + string => filestat("/tmp/already-existing-link", "linktarget"); + + classes: + "ok" + expression => and ( + strcmp("$(my_file_stat)", "/tmp/my-file"), + strcmp("$(my_dir_stat)", "/tmp/my-dir"), + strcmp("$(replaced_link_stat)", "/tmp/my-dir"), + strcmp("$(already_existing_link_stat)", "/tmp/other-dir"), + "created_file_repaired", + "created_dir_repaired", + "corrected_repaired", + "didnothing_kept" + ); + + reports: + ok:: + "$(this.promise_filename) Pass"; + !ok:: + "$(this.promise_filename) FAIL"; + +} + +# ####################################################### + +bundle agent cleanup +{ + files: + "/tmp/file-link" + delete => tidy; + "/tmp/dir-link" + delete => tidy; + "/tmp/my-file" + delete => tidy; + "/tmp/my-dir/." + delete => tidy; + "/tmp/other-dir/." + delete => tidy; + "/tmp/replaced-link" + delete => tidy; + "/tmp/already-existing-link" + delete => tidy; +}