Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support URI sources in write_files module #5505

Merged
merged 24 commits into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
53cfae8
cla: add LRitzdorf to CLA signers file
LRitzdorf Jul 10, 2024
fc856f8
feat(write_files): add initial support for URL source
LRitzdorf Jul 10, 2024
66b19ca
feat(write_files): set retries, SSL params for URL source
LRitzdorf Jul 10, 2024
bf777ea
feat(write_files): document `source` parameter in jsonschema
LRitzdorf Jul 10, 2024
d0422d7
docs(write_files): describe URL `source` usage
LRitzdorf Jul 10, 2024
44839b6
docs(runcmd): remove wget example
LRitzdorf Jul 10, 2024
05cdc73
feat(write_files): pass SSL details dict directly
LRitzdorf Jul 11, 2024
561128e
test(write_files): read content from `file://` URI
LRitzdorf Jul 11, 2024
792a58e
fix(write_files): properly retrieve content from URI
LRitzdorf Jul 11, 2024
c5adce9
test(write_files): fall back to "content" when "source" unavailable
LRitzdorf Jul 12, 2024
4f97281
fix(write_files): extract raw, not UTF-8, data from URL
LRitzdorf Jul 12, 2024
9d25d9e
fix(write_files): function-ize "read URL with fallback"
LRitzdorf Jul 12, 2024
1c27f77
fix(write_files): don't fall back to empty `content`
LRitzdorf Jul 12, 2024
ce9dbe6
feat(write_files): support URI load with HTTP headers
LRitzdorf Jul 12, 2024
6989bc9
test(write_files): load content from HTTP URI
LRitzdorf Jul 12, 2024
3048a06
fix(write_files): use typing's Optional, not union
LRitzdorf Jul 12, 2024
519332e
fix(write_files): appease auto-formatter
LRitzdorf Jul 12, 2024
662dc25
fix(write_files): `headers` is object, not array of them
LRitzdorf Jul 12, 2024
1c55f6c
test(write_files): add headers to HTTP test case
LRitzdorf Jul 12, 2024
9c42a15
fix(write_files): appease `ruff` format check
LRitzdorf Jul 18, 2024
041ed49
fix(write_files): use less confusable variable names
LRitzdorf Jul 18, 2024
3ae34bd
doc(write_files): update usage example with headers
LRitzdorf Jul 18, 2024
48c0072
fix(write_files): write an empty file if no content or URI
LRitzdorf Jul 18, 2024
368a214
fix(write_files): force `source.headers` values to be strings
LRitzdorf Jul 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions cloudinit/config/cc_write_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
import base64
import logging
import os
from typing import Optional

from cloudinit import util
from cloudinit import url_helper, util
from cloudinit.cloud import Cloud
from cloudinit.config import Config
from cloudinit.config.schema import MetaSchema
Expand Down Expand Up @@ -44,7 +45,8 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
name,
)
return
write_files(name, filtered_files, cloud.distro.default_owner)
ssl_details = util.fetch_ssl_details(cloud.paths)
write_files(name, filtered_files, cloud.distro.default_owner, ssl_details)


def canonicalize_extraction(encoding_type):
Expand Down Expand Up @@ -72,7 +74,7 @@ def canonicalize_extraction(encoding_type):
return [TEXT_PLAIN_ENC]


def write_files(name, files, owner: str):
def write_files(name, files, owner: str, ssl_details: Optional[dict] = None):
if not files:
return

Expand All @@ -86,8 +88,23 @@ def write_files(name, files, owner: str):
)
continue
path = os.path.abspath(path)
extractions = canonicalize_extraction(f_info.get("encoding"))
contents = extract_contents(f_info.get("content", ""), extractions)
# Read content from provided URL, if any, or decode from inline
contents = read_url_or_decode(
f_info.get("source", None),
ssl_details,
f_info.get("content", None),
f_info.get("encoding", None),
)
if contents is None:
LOG.warning(
LRitzdorf marked this conversation as resolved.
Show resolved Hide resolved
"No content could be loaded for entry %s in module %s;"
" skipping",
i + 1,
name,
)
continue
# Only create the file if content exists. This will not happen, for
# example, if the URL fails and no inline content was provided
(u, g) = util.extract_usergroup(f_info.get("owner", owner))
perms = decode_perms(f_info.get("permissions"), DEFAULT_PERMS)
omode = "ab" if util.get_cfg_option_bool(f_info, "append") else "wb"
Expand Down Expand Up @@ -118,6 +135,43 @@ def decode_perms(perm, default):
return default


def read_url_or_decode(source, ssl_details, content, encoding):
url = None if source is None else source.get("uri", None)
use_url = bool(url)
# Special case: empty URL and content. Write a blank file
if content is None and not use_url:
return ""
# Fetch file content from source URL, if provided
result = None
if use_url:
try:
# NOTE: These retry parameters are arbitrarily chosen defaults.
# They have no significance, and may be changed if appropriate
result = url_helper.read_file_or_url(
url,
headers=source.get("headers", None),
retries=3,
sec_between=3,
ssl_details=ssl_details,
).contents
except Exception:
util.logexc(
LOG,
'Failed to retrieve contents from source "%s"; falling back to'
' data from "contents" key',
url,
)
use_url = False
# If inline content is provided, and URL is not provided or is
# inaccessible, parse the former
if content is not None and not use_url:
# NOTE: This is not simply an "else"! Notice that `use_url` can change
# in the previous "if" block
extractions = canonicalize_extraction(encoding)
result = extract_contents(content, extractions)
return result


def extract_contents(contents, extraction_types):
result = contents
for t in extraction_types:
Expand Down
3 changes: 2 additions & 1 deletion cloudinit/config/cc_write_files_deferred.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
name,
)
return
write_files(name, filtered_files, cloud.distro.default_owner)
ssl_details = util.fetch_ssl_details(cloud.paths)
write_files(name, filtered_files, cloud.distro.default_owner, ssl_details)
22 changes: 22 additions & 0 deletions cloudinit/config/schemas/schema-cloud-config-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -3386,6 +3386,28 @@
"default": "''",
"description": "Optional content to write to the provided ``path``. When content is present and encoding is not 'text/plain', decode the content prior to writing. Default: ``''``"
},
"source": {
"type": "object",
"description": "Optional specification for content loading from an arbitrary URI",
"additionalProperties": false,
"properties": {
"uri": {
"type": "string",
"format": "uri",
"description": "URI from which to load file content. If loading fails repeatedly, ``content`` is used instead."
},
"headers": {
LRitzdorf marked this conversation as resolved.
Show resolved Hide resolved
"type": "object",
"description": "Optional HTTP headers to accompany load request, if applicable",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"uri"
]
},
"owner": {
"type": "string",
"default": "root:root",
Expand Down
1 change: 0 additions & 1 deletion doc/module-docs/cc_runcmd/example1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,3 @@ runcmd:
- [sh, -xc, 'echo $(date) '': hello world!''']
- [sh, -c, echo "=========hello world'========="]
- ls -l /root
- [wget, 'http://example.org', -O, /tmp/index.html]
10 changes: 8 additions & 2 deletions doc/module-docs/cc_write_files/data.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ cc_write_files:
Write out arbitrary content to files, optionally setting permissions.
Parent folders in the path are created if absent. Content can be specified
in plain text or binary. Data encoded with either base64 or binary gzip
data can be specified and will be decoded before being written. For empty
file creation, content can be omitted.
data can be specified and will be decoded before being written. Data can
also be loaded from an arbitrary URI. For empty file creation, content can
be omitted.

.. note::
If multi-line data is provided, care should be taken to ensure it
Expand Down Expand Up @@ -36,5 +37,10 @@ cc_write_files:
Example 5: Defer writing the file until after the package (Nginx) is
installed and its user is created.
file: cc_write_files/example5.yaml
- comment: >
Example 6: Retrieve file contents from a URI source, rather than inline.
Especially useful with an external config-management repo, or for large
binaries.
file: cc_write_files/example6.yaml
name: Write Files
title: Write arbitrary files
9 changes: 9 additions & 0 deletions doc/module-docs/cc_write_files/example6.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#cloud-config
write_files:
- source:
uri: https://gitlab.example.com/some_ci_job/artifacts/hello
headers:
Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
User-Agent: cloud-init on myserver.example.com
path: /usr/bin/hello
permissions: '0755'
82 changes: 82 additions & 0 deletions tests/unittests/config/test_cc_write_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import tempfile

import pytest
import responses

from cloudinit import util
from cloudinit.config.cc_write_files import decode_perms, handle, write_files
Expand Down Expand Up @@ -84,6 +85,16 @@ def test_simple(self):
)
self.assertEqual(util.load_text_file(filename), expected)

def test_empty(self):
self.patchUtils(self.tmp)
filename = "/tmp/my.file"
write_files(
"test_empty",
[{"path": filename}],
self.owner,
)
self.assertEqual(util.load_text_file(filename), "")

def test_append(self):
self.patchUtils(self.tmp)
existing = "hello "
Expand Down Expand Up @@ -167,6 +178,71 @@ def test_handle_plain_text(self):
"Unknown encoding type text/plain", self.logs.getvalue()
)

def test_file_uri(self):
self.patchUtils(self.tmp)
src_path = "/tmp/file-uri"
dst_path = "/tmp/file-uri-target"
content = "asdf"
util.write_file(src_path, content)
cfg = {
"write_files": [
{
"source": {"uri": "file://" + src_path},
"path": dst_path,
}
]
}
cc = self.tmp_cloud("ubuntu")
handle("ignored", cfg, cc, [])
self.assertEqual(
util.load_text_file(src_path), util.load_text_file(dst_path)
)

@responses.activate
def test_http_uri(self):
self.patchUtils(self.tmp)
path = "/tmp/http-uri-target"
url = "http://hostname/path"
content = "more asdf"
responses.add(responses.GET, url, content)
cfg = {
"write_files": [
{
"source": {
"uri": url,
"headers": {
"foo": "bar",
"blah": "blah",
},
},
"path": path,
}
]
}
cc = self.tmp_cloud("ubuntu")
handle("ignored", cfg, cc, [])
self.assertEqual(content, util.load_text_file(path))

def test_uri_fallback(self):
self.patchUtils(self.tmp)
src_path = "/tmp/INVALID"
dst_path = "/tmp/uri-fallback-target"
content = "asdf"
util.del_file(src_path)
cfg = {
"write_files": [
{
"source": {"uri": "file://" + src_path},
"content": content,
"encoding": "text/plain",
"path": dst_path,
}
]
}
cc = self.tmp_cloud("ubuntu")
handle("ignored", cfg, cc, [])
self.assertEqual(content, util.load_text_file(dst_path))

def test_deferred(self):
self.patchUtils(self.tmp)
file_path = "/tmp/deferred.file"
Expand Down Expand Up @@ -249,6 +325,12 @@ class TestWriteFilesSchema:
"write_files": [
{
"append": False,
"source": {
"uri": "http://a.com/a",
"headers": {
"Authorization": "Bearer SOME_TOKEN"
},
},
"content": "a",
"encoding": "text/plain",
"owner": "jeff",
Expand Down
1 change: 1 addition & 0 deletions tools/.github-cla-signers
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ licebmi
linitio
LKHN
lkundrak
LRitzdorf
lucasmoura
lucendio
lungj
Expand Down
Loading