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

Add ftp support to NoCloud #4834

Merged
merged 21 commits into from
Apr 15, 2024
Merged

Conversation

holmanb
Copy link
Member

@holmanb holmanb commented Feb 1, 2024

Proposed Commit Message

feat: Add support for FTP and FTP over TLS

Use Python's ftplib to connect to and read instance configurations from a
remove server. Like the current http support, nocloud's configuration URI
is defined via kernel command line or dmi product serial.

Notes on FTP and FTP over TLS
=============================

FTP is insecure, use at your own risk.

The FTP over TLS implementation follows library best practices[1][2].
From the ssl documentation regarding create_default_context():

> It will load the system’s trusted CA certificates, enable certificate
> validation and hostname checking, and try to choose reasonably secure
> protocol and cipher settings.

Test Changes
============

tests/i/util.py
---------------

- new helper function verify_clean_boot() to verify cloud-init state
- acquire override_kernel_cmdline() for reuse in other tests
- acquire restart_cloud_init() for reuse in other tests

tests/i/datasources/test_nocloud.py
-----------------------------------

- new test class for FTP/FTPS uses pyftpdlib for server, mkcert for test certs
- 2 test additions for FTP
- 2 test additions for FTPS

Implements via ftplib
=====================

RFC 959 - File Transfer Protocol
RFC 2640 - FTP Internationalization (UTF-8)
RFC 4217 - FTP over TLS

[1] https://docs.python.org/3/library/ftplib.html#ftp-tls-objects
[2] https://docs.python.org/3/library/ssl.html#ssl-security

Additional Context

Test Steps

PLATFORM="lxd_vm" CLOUD_INIT_SOURCE="./cloud-init_all.deb" python -m
pytest -vv --log-cli-level=INFO --durations 10 tests/integration_tests/datasources/test_nocloud.py::TestFTP

Merge type

  • Squash merge using "Proposed Commit Message"
  • Rebase and merge unique commits. Requires commit messages per-commit each referencing the pull request number (#<PR_NUM>)

@holmanb holmanb added the wip Work in progress, do not land label Feb 1, 2024
@@ -2968,8 +2968,6 @@ def is_x86(uname_arch=None):


def message_from_string(string):
if sys.version_info[:2] < (2, 7):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bwahaha!

@TheRealFalcon
Copy link
Member

@holmanb this still WIP?

@TheRealFalcon TheRealFalcon self-assigned this Feb 13, 2024
@holmanb holmanb added the 24.1 label Feb 13, 2024
@holmanb
Copy link
Member Author

holmanb commented Feb 13, 2024

@holmanb this still WIP?

I'm working on an integration test for it currently, but I think it is ready for review.

Copy link
Member

@TheRealFalcon TheRealFalcon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides the test issues, I wanted to provide my other comments here now

cloudinit/url_helper.py Outdated Show resolved Hide resolved
cloudinit/url_helper.py Outdated Show resolved Hide resolved
cloudinit/url_helper.py Outdated Show resolved Hide resolved
return FtpResponse(url_parts.path, contents=buffer)
except ftplib.all_errors as e:
if "ftps" == url_parts.scheme:
raise UrlError(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like an overly targeted exception for a large block of code in the try block. E.g., if we fail on the ftp_tls.retrbinary(f"RETR {url_parts.path}", callback=buffer.write) line, that wouldn't be because of the error being raised here, correct?

Can just the connect line be in the try block while letting all other exceptions escape?

Similar comment for the try after this one

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can just the connect line be in the try block while letting all other exceptions escape?

I'm hesitant to do that in the first try/except block, but I'm fine with doing it in the second.

If prot_p() throws an exception and we're using ftp:// (which permits unencrypted ftp) I think we should try to recover so we can attempt unencrypted.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TheRealFalcon I think I've done what you asked, but I'm not certain I fully understood. Let me know.

@holmanb holmanb force-pushed the holmanb/nocloud-ftp branch 4 times, most recently from abdc4bd to ec6ee22 Compare February 24, 2024 02:38
@holmanb holmanb removed the wip Work in progress, do not land label Feb 24, 2024
@holmanb holmanb changed the title [WIP]: Add ftp support to NoCloud Add ftp support to NoCloud Feb 24, 2024
@holmanb
Copy link
Member Author

holmanb commented Feb 24, 2024

@TheRealFalcon I think I've addressed your comments. I've added integration tests and updated the commit message.

Ready for re-review.

Copy link

@setharnold setharnold left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added some nitpicks, feel free to disregard if you disagree strongly enough. Thanks

#
# instructions from https://github.com/FiloSottile/mkcert
assert client.execute(
"git clone https://github.com/FiloSottile/mkcert && "

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes where this can be run, and how safe it is to run this test. Could we package a fixed cert.pem and key.pem (set to expire in a hundred years or so?) to skip this download, compile, and execute?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we package a fixed cert.pem and key.pem (set to expire in a hundred years or so?) to skip this download, compile, and execute?

Given that this will be dropped as soon as we are no longer releasing to focal, I'm not sure that it is worth it. Also, I could use help with cert generation for that.

The challenging bit wasn't actually getting a cert.pem and key.pem, it was building a root cert and getting it into the CA store so that the client would trust it - self signed certs won't work for that. I'm not very well versed in the openssl commandline, but if someone is able to generate a test root CA, cert, and key I'd be happy to use that rather than using mkcert.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"no longer releasing to focal" may be a very long way away.

The security team has some rough-and-ready python for testing OpenSSL that might be suitable for stealing:

https://git.launchpad.net/qa-regression-testing/tree/scripts/test-openssl.py#n238

tests/integration_tests/datasources/test_nocloud.py Outdated Show resolved Hide resolved
required_warnings_found = set()
required_errors_found = set()

for current_error in status["errors"]:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These loops are more complex than I'd like. They might work perfectly well, but it isn't obvious to me that they do. I would find set operations easier, eg:

missing_errors = require_errors.subtract(status["errors"])
surplus_errors = status["errors"].subtract(require_errors).subtract(ignore_errors)

(or whatever would actually run.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this would be easier logic to follow, however set operations require exact matches rather than allowing substring matches (what the code currently does), or regex matches (what we probably want in the future). I haven't yet thought of an elegant way to accomplish this using sets.

Copy link
Member

@TheRealFalcon TheRealFalcon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally LGTM...just mostly test requests inline.

code = NOT_FOUND
raise UrlError(cause=e, code=code, headers=None, url=url) from e
return FileResponse(file_path, contents=contents)
parsed = urlparse(url)
Copy link
Member

@TheRealFalcon TheRealFalcon Feb 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible for this function to fail and raise an error. I can't think of any possible way that would happen on input that wouldn't also fail on one of the later functions, but have you considered all of the possible failure cases? I don't think the code needs to change, but just an ask to double-check any possible exceptions and ensure we won't newly fail on them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed with exception handling

return f"\n{header}:\n\t- " + "\n\t- ".join(items)


def verify_clean_boot(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verify_clean_log has gotten pretty crusty, and this is a much nicer direction to replace it.

That said, verify_clean_log was never intended to be a collection of problems that might appear for this particular test setup. Rather, it's supposed to be a collection of problems that can appear for any test based on the platform.

E.g., every test on Azure has to work around No lease found. Every test that runs on Oracle has to work around Stderr: RTNETLINK answers: File exists.

It's obviously grown into something grosser than that, but the behavior is still required.

This happens to work for your test because you've limited your test to LXD_VMs, but if we want a general purpose utility function, we need both behaviors. At the very least I think the new function should call verify_clean_log for now. Then we can either later clean up verify_clean_log or refactor the needed pieces into this one.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the very least I think the new function should call verify_clean_log for now.

I think that at the moment, verify_clean_log just won't work unless you pass matching expected failures. This is the result of the "Found unexpected errors/warnings" code paths - it just won't work unless you've passed everything that you expect to see in the ignored_warnings and ignored_errors keys. I don't think that calling verify_clean_log() inside of verify_clean_boot() will fix that - in fact we can't because we require failures in some of our tests, but I'm happy to call that manually as well in the integration tests that we expect a clean log in.

One idea that I envision for this helper is a matrix of platforms and series that allows us to specify exactly when an expected warning message that will be seen. This would be more precise than the current "ignore this pattern everywhere" paradigm that verify_clean_log() uses. That said, I'm not sure that I want to include that refactor in this PR, which has already grown sizeable.

tests/integration_tests/util.py Outdated Show resolved Hide resolved
tests/integration_tests/util.py Outdated Show resolved Hide resolved
tests/integration_tests/util.py Outdated Show resolved Hide resolved
@holmanb holmanb removed the 24.1 label Mar 12, 2024
@holmanb holmanb force-pushed the holmanb/nocloud-ftp branch 2 times, most recently from 3b7dc27 to 170b061 Compare March 15, 2024 23:46
@holmanb
Copy link
Member Author

holmanb commented Mar 19, 2024

@TheRealFalcon I believe that I've addressed all of your comments.

I have one more review item left to address, which is the request to include a manually generated cert rather than installing mkcert from source for focal. I won't be able to work on that request until Friday at the earliest, so if you'd like to re-review the non-test code, I don't have any more planned changes there.

Copy link
Member

@TheRealFalcon TheRealFalcon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: still some c variables in function signatures, but otherwise LGTM

tests/integration_tests/util.py Outdated Show resolved Hide resolved
tests/integration_tests/util.py Outdated Show resolved Hide resolved
Copy link

github-actions bot commented Apr 5, 2024

Hello! Thank you for this proposed change to cloud-init. This pull request is now marked as stale as it has not seen any activity in 14 days. If no activity occurs within the next 7 days, this pull request will automatically close.

If you are waiting for code review and you are seeing this message, apologies! Please reply, tagging TheRealFalcon, and he will ensure that someone takes a look soon.

(If the pull request is closed and you would like to continue working on it, please do tag TheRealFalcon to reopen it.)

@github-actions github-actions bot added the stale-pr Pull request is stale; will be auto-closed soon label Apr 5, 2024
@holmanb holmanb removed the stale-pr Pull request is stale; will be auto-closed soon label Apr 12, 2024
Make ftps:// only support ftp over TLS
Make ftp:// only support unencrypted ftp

Add integration tests that assert the following:

ftp:// against an ftp-only server succeeds at retrieving configs (doesn't use TLS)
ftp:// against an ftps-only server fails (doesn't use TLS)
ftps:// against an ftps-only server succeeds at retrieving configs (uses TLS)
ftps:// against an ftp-only server succeeds (cannot use TLS)
@holmanb
Copy link
Member Author

holmanb commented Apr 15, 2024

rebased and force pushed - addressed remaining comment from @TheRealFalcon and merging

@holmanb holmanb merged commit b9aff94 into canonical:main Apr 15, 2024
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants