Repository: saltstack/salt
Pull request: [master] file.keyvalue should allow creating a file if it doesn't exist
### What does this PR do?
Adds the `create_if_missing` parameter to handle basic file creation for `file.keyvalue`

### What issues does this PR fix or reference?
Fixes: #63545

### Previous Behavior
`file.keyvalue` could not create a file, so `file.touch` or `file.managed` without content were often used.

### New Behavior
Basic file creation is now possible, although management of permissions and ownership is not.

### Merge requirements satisfied?
**[NOTICE] Bug fixes or features added to Salt require tests.**
<!-- Please review the [test documentation](https://docs.saltproject.io/en/master/topics/tutorials/writing_tests.html) for details on how to implement tests into Salt's test suite. -->
- [X] Docs
- [X] Changelog - https://docs.saltproject.io/en/master/topics/development/changelog.html
- [X] Tests written/updated

### Commits signed with GPG?
Yes

Please review [Salt's Contributing Guide](https://docs.saltproject.io/en/master/topics/development/contributing.html) for best practices.

See GitHub's [page on GPG signing](https://help.github.com/articles/signing-commits-using-gpg/) for more information about signing commits with GPG.


Problem statement:
file.keyvalue should create the file if it doesn't exist
If you don't yet have a file that you would like to create by key/value pairs, then this state should just create it.
Or at least be able to do it by using a "create=xxx" option.

Hints from issue discussion:
Hi there! Welcome to the Salt Community! Thank you for making your first contribution. We have a lengthy process for issues and PRs. Someone from the Core Team will follow up as soon as possible. In the meantime, here’s some information that may help as you continue your Salt journey.
Please be sure to review our [Code of Conduct](https://github.com/saltstack/salt/blob/master/CODE_OF_CONDUCT.md). Also, check out some of our community resources including:

  - [Community Wiki](https://github.com/saltstack/community/wiki)
  - [Salt’s Contributor Guide](https://docs.saltproject.io/en/master/topics/development/contributing.html)
  - [Join our Community Slack](https://join.slack.com/t/saltstackcommunity/shared_invite/zt-3av8jjyf-oBQ2M0vhXOhJpNpRkPWBvg)
  - [IRC on LiberaChat](https://web.libera.chat/#salt)
  - [Salt Project YouTube channel](https://www.youtube.com/channel/UCpveTIucFx9ljGelW63-BWg)
  - [Salt Project Twitch channel](https://www.twitch.tv/saltprojectoss)

There are lots of ways to get involved in our community. Every month, there are around a dozen opportunities to meet with other contributors and the Salt Core team and collaborate in real time. The best way to keep track is by subscribing to the Salt Community Events Calendar.
If you have additional questions, email us at saltproject@vmware.com. We’re glad you’ve joined our community and look forward to doing awesome things with you!

If this feature is added, then `file.replace` should also get it.

If you are wanting to manage (and create) an entire key-value file, then in 3006 you will be able to use `file.serialize` with `serializer: keyvalue`.

Commit message: Merge branch 'master' into add-keyvalue-create_if_missing

Patch to review:
diff --git a/changelog/63545.added.md b/changelog/63545.added.md
new file mode 100644
index 000000000000..c53ef8dda802
--- /dev/null
+++ b/changelog/63545.added.md
@@ -0,0 +1 @@
+Add ability for file.keyvalue to create a file if it doesn't exist
diff --git a/salt/states/file.py b/salt/states/file.py
index 9f32151b8b1e..fb7caf988b51 100644
--- a/salt/states/file.py
+++ b/salt/states/file.py
@@ -5418,6 +5418,7 @@ def keyvalue(
     uncomment=None,
     key_ignore_case=False,
     value_ignore_case=False,
+    create_if_missing=False,
 ):
     """
     Key/Value based editing of a file.
@@ -5491,6 +5492,11 @@ def keyvalue(
         the current value is 'yes', will not result in changes when
         ``value_ignore_case`` is set to True.
 
+    create_if_missing
+        Create the file if the destination file is not found.
+
+        .. versionadded:: 3007.0
+
     An example of using ``file.keyvalue`` to ensure sshd does not allow
     for root to login with a password and at the same time setting the
     login-gracetime to 1 minute and disabling all forwarding:
@@ -5564,7 +5570,16 @@ def keyvalue(
     try:
         with salt.utils.files.fopen(name, "r") as fd:
             file_contents = fd.readlines()
-    except OSError:
+            fd.close()
+    except FileNotFoundError as exc:
+        if create_if_missing:
+            append_if_not_found = True
+            file_contents = []
+        else:
+            ret["comment"] = "unable to open {n}".format(n=name)
+            ret["result"] = True if ignore_if_missing else False
+            return ret
+    except OSError as exc:
         ret["comment"] = "unable to open {n}".format(n=name)
         ret["result"] = True if ignore_if_missing else False
         return ret
@@ -5692,8 +5707,6 @@ def keyvalue(
         # keys needed searching), the line can be added to the content to be
         # written once the last checks have been performed
         content.append(line)
-    # finally, close the file
-    fd.close()
 
     # if append_if_not_found was requested, then append any key/value pairs
     # still having a count left on them
diff --git a/tests/pytests/unit/states/file/test_keyvalue.py b/tests/pytests/unit/states/file/test_keyvalue.py
index 396e29aea6d9..c0b090f97d19 100644
--- a/tests/pytests/unit/states/file/test_keyvalue.py
+++ b/tests/pytests/unit/states/file/test_keyvalue.py
@@ -120,3 +120,30 @@ def test_file_keyvalue_not_dict(tmp_path):
         f_contents = tempfile.read_text()
         assert "PermitRootLogin yes" not in f_contents
         assert "#StrictMode yes" in f_contents
+
+
+def test_file_keyvalue_create_if_missing(tmp_path):
+    tempfile = tmp_path / "tempfile"
+    assert not tempfile.exists()
+
+    ret = filestate.keyvalue(
+        name=str(tempfile),
+        key="myKey",
+        value="likesIt",
+        create_if_missing=False,
+    )
+    assert ret["result"] is False
+    assert not tempfile.exists()
+
+    ret = filestate.keyvalue(
+        name=str(tempfile),
+        key="myKey",
+        value="likesIt",
+        create_if_missing=True,
+    )
+    assert ret["result"] is True
+    assert tempfile.exists()
+    f_contents = tempfile.read_text()
+    assert "myKey=likesIt" in f_contents
+
+    tempfile.unlink()


Code review comments:
diff_hunk: @@ -5564,7 +5570,16 @@ def keyvalue(
     try:
         with salt.utils.files.fopen(name, "r") as fd:
             file_contents = fd.readlines()
-    except OSError:
+            fd.close()
path: salt/states/file.py
text: @user1:
Shouldn't the context manager close this file properly? Why are you adding the close call here?

@author:
I just got caught up in the magnificent chaos of it all.