Merge ~jibel/curtin/+git/add-layer-images-support:master into curtin:master

Proposed by Jean-Baptiste Lallement
Status: Merged
Approved by: Ryan Harper
Approved revision: a333c135a5d9192791697cec586562a4bb8be6df
Merge reported by: Server Team CI bot
Merged at revision: not available
Proposed branch: ~jibel/curtin/+git/add-layer-images-support:master
Merge into: curtin:master
Diff against target: 737 lines (+544/-44)
8 files modified
curtin/commands/extract.py (+119/-1)
curtin/commands/install.py (+0/-4)
curtin/url_helper.py (+36/-24)
curtin/util.py (+5/-1)
doc/topics/config.rst (+76/-2)
tests/unittests/helpers.py (+20/-0)
tests/unittests/test_commands_extract.py (+227/-3)
tests/unittests/test_url_helper.py (+61/-9)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
Ryan Harper (community) Approve
Chad Smith Approve
Scott Moser (community) Needs Information
Review via email: mp+359761@code.launchpad.net

Commit message

Support for multi-layers images fsimage-layered:// URI

Curtin can now download and mount a layered filesystem image for use
as a source image. Local file or url are supported. Filesystem can be
any filesystem type mountable by the running kernel.

A "fsimage-layered" install source is a string representing one or
more mountable images from a single local or remote directory. The
string is dot-separated where each value between the dots represents a
particular image and the location of the name within the string
encodes the order in which it is to be mounted. The resulting list of
images are downloaded (if needed) then mounted and overlayed into a
single directory which is used as the source for installation.

See documentation for further details.

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

Maybe it would be more sane to add 'fsimage-stack://' or
'layered-fsimage://' rather than extending fsimage://.

Then we dont have to make the 'fsimage:' path any more complicated.

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

Adding a new source type was our initial thought too. But it finally made more sense to extend the existing fsimage.
Ultimately the API is the same and a path to a single filesystem image is passed as an installation source. The user doesn't have to have the knowledge about the type of build (flat image or layered). Moreover the code to handle a flat image or a layered image in Curtin is very similar, the main difference being the computation of the path then merging with an overlay. It reduces the redundancy of the code and its overall complexity is factorized into this single fsimage type.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

@Jibel,

Thanks for fixing the lint errors.

You suggest that "The user doesn't have to have the knowledge about the
type of build (flat image or layered)." but reality is that the user
probably *does* know that, or is easily updated to know that. While
"figuring it out" in curtin is heuristic.

The thing I don't like about it is that currently, fsimage can be a url.
  fsimage://http://some.thing.here/path/to/image

We'd have to build in a lot of heuristics to make that work for
   fsimage://http://some.thing.here/path/to/image/multipart/

But it doesn't take much other than a 'image-index.json' or something
if we make the user tell it that it is a layered image.
Ie, we can make both of these work:
  layered-fsimage:///path/to/some/dir
and
  layered-fsimage://http://example.com/path/

the url would just require that it either be given a path to an
image-index.json or it would add image-index.json. Then image-index.json
would just contain
something like this:
 {
   "images": [
     "01-main1.fs",
     "02-main2.fs",
     "02-main2/sublayer1.fs",
     "02-main2/sublayer2.fs",
     "02-main2/sublayer2/sublayera.fs",
     "02-main2/sublayer2/sublayerb.fs",
     "03-main3.fs"
   ],
   "version": 1,
 }

Revision history for this message
Didier Roche-Tolomelli (didrocks) wrote :

Hey Scott,

you should have a look at the tests, the sublayering works actually a bit different than you think :)

You can only point to the final layers (the topmost), then, the structure on disk built with livecd-rootfs will find its way to the root layer (all layers stack on top of each other and can have one single children).
This is because there is only a single path to the root layers, and they can not be composed that way. If we force the user to explicitely set them manually, it means we'll have another validation round to do in curtin. For instance, you can't compose "02-main2/sublayer2/sublayera.fs", "02-main2/sublayer2/sublayerb.fs".

If the image is invalid, the current proposed code will raise an exception.

I don't think we have anything against a syntax "foo:///pathtosomesquashfs". However, we spotted that the current curtin syntax, if we are correct, is "fsimage: /path/to/some/squashfs", which contradicts the current published docs. Is that an issue on the code?
Having then one single codepath with our additional tests enable skipping some code duplication, as both only points to one single squashfs file.

Does it make more sense?
(Note: jibel is away for a week and half)

Revision history for this message
Scott Moser (smoser) wrote :

@Didier,

(Note I did type incorrectly previously with 'fsimage://http://'
the shortcut fsimage http support is like fsimage:http://).

I agree I don't really understand how _get_image_paths works.
But that isn't really my issue.

The issue is that the support added for layered images
relies upon doing a directory listing of its input
(via glob.glob1 call in _get_image_paths).

That means that fsimage:http://example.com/your/path/to/some/layered.fs
will not work because we can't do a listing of the contents on
http://example.com/your/path/to/some .

Rather than extending fsimage: to work for layered filesystem images
that are local and fail in an inconsistent way for remote layeredd filesystem
images, I'd prefer either:
 a.) to add support for 'fsimage-layered:' that supports only local
     filesystems and errors if provided with a fsimage-layered:http://..
or
 b.) to add some mechanism for "list"ing a remote layered image. That
     is what I was suggesting with the index.

Revision history for this message
Didier Roche-Tolomelli (didrocks) wrote :

@Scott:
Ack, the remote use-case is indeed a valid argument. I'll rediscuss that with Jean-Baptiste once he's back mid next-week and we'll fix it (I guess with a separate 'fsimage-layered:', but I'll wait on jibel) and give another round of testing as those layered systems only makes sense for a local filesystem (otherwise, you would just point to one, pre-compiled, full squashfs IMHO).

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

@Scott, following your advice, this update adds a separate fsimage-layered type to manage multi-layers images.
The tests and documentation have been updated accordingly.

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

This branch has been rebased on master.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

commit message in this MP needs updating.

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

Updated.

Revision history for this message
Scott Moser (smoser) wrote :

Thanks for the updates. This does look pretty good.

A couple of minor things inline below.
One thing that needs fixing is that now 'fsimage' will behave like fsimage-layered.

Thank you for your patience and for re-working some things.

And welcome back from holiday.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

Thanks for the review, all your points have been addressed

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

https://code.launchpad.net/~smoser/curtin/+git/curtin/+merge/360950
I put some changes there that i think make sense. As of
 c65cd19723140ca9a4cebc50a60fa6fb8be7ff1e
you should be able to just pull.

2 other comments:
a.) it sure would be nice to have *some* test of extract_root_layered_fsimage.
b.) ultimately we're going to need some vmtest here. Thats our only way to do integration test and actually ensure that this doesnt work.

I dont' want to push on 'b' right now, but we do need it for sure.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

I merged you proposed changes and fixed the documentation.

Revision history for this message
Scott Moser (smoser) wrote :

One inline comment that you missed in previous review.

review: Needs Information
Revision history for this message
Scott Moser (smoser) wrote :

http://paste.ubuntu.com/p/ycp2Hwy3zX/

^ that adds a very light test to the happy path of extract.

Revision history for this message
Ryan Harper (raharper) wrote :

I'm OK with the branch. I think the critical element to capture is:

1) what is the expected input (what's the structure of the input, directories of files)
2) what is the expected output (an overlayroot with a set of images mounted)

I want to make sure that curtin logs what it has found and mounted as this is our
contract with the caller of curtin. If the result of the copy of the overlay'ed dir does not succeed or does not contain the expected output then we'll want the logs to provide details as to what composed the overlay mountpoint.

Lastly, just for my understanding; is there a specific reason why curtin needs to do the mounting and creation of the overlay? Is this layered fs structure and overlay something that's in an existing product?

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

Thanks for the review

About 1 and 2, this is documented in, where do you want to capture these elements? Or is there something to clarify in the documentation?

Ok, to add more logs but we already log the list of image detected and if an error occurs, the image that caused this error. What else would you like to log?
We could add more debug output in _get_image_path but since it's recursive it would be excessively verbose.

About your last point, the goal is to provide to curtin a single leaf filesystem in its yaml config file and it mounts the entire hierarchy of filesystems without any knowledge extra in the caller of curtin. It's very similar to the existing fsimage but for multilayer filesystems. This is implemented for the desktop images and is a pre-requisite for the new desktop installer which will rely on curtin.

For instance for the desktop the filesystems will be minimal, standard, and languages packs for minimal and standard, depending on the choice of the user (minimal vs normal and the language) We'll point curtin to the right squashfs containing the language pack and it will install the corresponding squashfses for the entire hierarchy. This has been tested successfully with this patch.

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

To illustrate this feature and for the sack of completeness, here is an example:

Curtin configuration file:

=====
showtrace: true
storage:
    version: 1
    config:
      - id: vda
        type: disk
        ptable: msdos
        path: /dev/vda
        wipe: superblock
        grub_device: true
      - id: vda1
        type: partition
        number: 1
        size: 10GB
        device: vda
        flag: boot
      - id: vda1_root
        type: format
        fstype: ext4
        volume: vda1
        label: 'desktop-rootfs'
      - id: vda1_mount
        type: mount
        path: /
        device: vda1_root
sources:
  - fsimage-layered://cdrom/casper/02-install/desktop-fr.squashfs
=====

Install a desktop from this configuration with this command:

$ sudo curtin -vv -c config.yaml install

Installation logs: http://paste.ubuntu.com/p/bFbrXbP7CQ/

The Desktop image used for this test is to big for my bandwidth to upload it somewhere but we can do a hangout if you want a live demo.

Revision history for this message
Ryan Harper (raharper) wrote :

The above is very helpful. I've spent a bit of time with the branch, a couple comments inline. I think we can remove the source sanitize in install.py (it's done in extract). And then I started on expanding the documentation.

It was very interesting to see how the implementation works; and I want to document the expectations between "main-layers" and other images. There are some interesting side-effects that I think we must document. In particular, only main images seem to have peers at the same level, for example showing nested main layers; that is we cannot put a set of main layers in a sublayer. Pointing at any of the sub-images in the tree below only gets the sub-image, no peers nor parents, is that expected?

├── 10-first
│   ├── 10-main-nest1.img
│   ├── 20-main-nest2
│   │   └── 10-main-nest2-nest1.img
│   ├── 20-main-nest2.img
│   └── 30-main-test3.img
├── 10-first.img
├── 20-second
│   ├── 20-second-nest1
│   │   ├── 30-second-nest1-nest2
│   │   │   └── 40-second-nest1-nest2-nest3.img
│   │   └── 30-second-nest1-nest2.img
│   └── 20-second-nest1.img
└── 20-second.img

Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :

So the basic idea of this looks fine to me and something that the live server could use (and would make the live server ISO somewhat simpler to assemble).

There is an aspect I struggle I a bit with though (and jibel knows this applies to the livecd-rootfs side too). This work introduces a new concept (well, the concept is there for the live server work but that's all done in a very by hand way there) which is the concept of a tree of filesystem overlays together with the need to be able to assemble the complete overlay just from the name of one of them. And then basically casper will set the root filesystem to be one of these choices and the installer will pass a different choice to curtin based on the users actions. The way this work does is one possible way of representing this -- and has the feature that no changes to casper are required for it to work -- but is it the best possible one? I find it a bit klunky, although I don't really have any amazingly better ideas. I would like us all to have thought about it explicitly though.

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :
Download full text (3.2 KiB)

>
> It was very interesting to see how the implementation works; and I want to
> document the expectations between "main-layers" and other images. There are
> some interesting side-effects that I think we must document. In particular,
> only main images seem to have peers at the same level, for example showing
> nested main layers; that is we cannot put a set of main layers in a sublayer.
> Pointing at any of the sub-images in the tree below only gets the sub-image,
> no peers nor parents, is that expected?
>

It's a very good point and not expected. We've fixed the behaviour to load a set of main layers in a sublayer. The only constraint is that the names must start with 2 digits. If they don't start with 2 digits it'll try again with fs in the parent directory until it reaches the uppermost level.

Here is the change that we will push when all the other comments are addressed

diff --git a/curtin/commands/extract.py b/curtin/commands/extract.py
index 1ab8f767..5e14f24d 100644
--- a/curtin/commands/extract.py
+++ b/curtin/commands/extract.py
@@ -148,7 +148,7 @@ def _get_image_paths(path):
     images = [path]

     m = re.match(r"([0-9]{2})-.*", imgname)
- # Main layers
+ # Directory with peer fs images
     if m:
         i = int(m.group(1)) - 1
         match = "{:02d}-*{}".format(i, fileext)
@@ -160,11 +160,11 @@ def _get_image_paths(path):
         if candidate:
             images.extend(
                 _get_image_paths(os.path.join(dirname, candidate[-1])))
- return images
- else:
- # Climbing up from uppermost squashfes sublayers
- images.extend(_get_image_paths(dirname + fileext))
- return images
+ return images
+
+ # Climbing up from uppermost squashfes sublayers
+ images.extend(_get_image_paths(dirname + fileext))
+ return images

 def copy_to_target(source, target):
diff --git a/tests/unittests/test_commands_extract.py b/tests/unittests/test_commands_extract.py
index b85f3a6c..b90ad8e2 100644
--- a/tests/unittests/test_commands_extract.py
+++ b/tests/unittests/test_commands_extract.py
@@ -123,6 +123,26 @@ class TestGetImagesPaths(CiTestCase):
             _get_image_paths(
                 os.path.join(tdir, "02-main2/sublayer2/sublayera.fs")))

+ def test_leading_digits_mounts_peer_images(self):
+ """_get_image_paths mounts peer images in a folder when starting with leading digits"""
+ tdir = self._create_temporary_fs_struct([
+ "main1.fs",
+ "main2.fs",
+ "main2/01-sublayer1.fs",
+ "main2/02-sublayer2.fs",
+ "main2/02-sublayer2/sublayera.fs",
+ "main2/02-sublayer2/sublayerb.fs",
+ "main3.fs",
+ ])
+ expected_base = ["main2/02-sublayer2/sublayerb.fs",
+ "main2/02-sublayer2.fs",
+ "main2/01-sublayer1.fs",
+ "main2.fs"]
+ self.assertEqual(
+ [os.path.join(tdir, p) for p in expected_base],
+ _get_image_paths(
+ os.path.join(tdir, "main2/02-sublayer2/sublayerb.fs")))
+
     def test_missing_file(self):
         """_get_image_paths returns an empty...

Read more...

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

See comments inline

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

This is one way to implement it. The reasoning behind pointing to a leaf filesystem then mount the filesystems included in the branch together with an overlay is that, as currently used by desktop and server live, there is a strong dependency chain between layers. Most of them are modifying common files, like apt database, seed.yaml snap… Thus, the order is important and they cannot be mounted arbitrarily. Reflecting this in the directory structure is a way to make it more obvious for the user and less error-prone.
We would have to check anyway in curtin that layers are validly stacked on top of each other, hence the idea to only point to a leaf filesystem.
One case not covered by this implementation is if you want layers that can be arbitrarily stacked on top of any other layer, with no common files (like data assets, debug symbols, external apps in /opt). However, does it cover any practical or existing use cases we have nowadays?
This could still be achieved with this system by using different extensions or directories for a distinct set of images and let curtin mount that list of leaf layers (after dependency resolution) on top of each other.
This would require to extend fsimage-layered to support a list of leaf images and modify casper mount several branches of images.

Revision history for this message
Didier Roche-Tolomelli (didrocks) wrote :

Thanks both for your feedbacks! We just pushed the code changes that should address the inline
comments and previous remarks + the doc changes to apply to the behavior changes with additional examples.

We rebased on origin/master and tested a VM install with those changes.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

This is looking quite nice. Thanks for updating the branch.

Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :

> This is one way to implement it. The reasoning behind pointing to a leaf
> filesystem then mount the filesystems included in the branch together with an
> overlay is that, as currently used by desktop and server live, there is a
> strong dependency chain between layers. Most of them are modifying common
> files, like apt database, seed.yaml snap… Thus, the order is important and
> they cannot be mounted arbitrarily. Reflecting this in the directory structure
> is a way to make it more obvious for the user and less error-prone.

That's not really the problem I have, it's more that I find the way the order is reflected in the directory structure to be a bit baroque. I've tried to explain further on the livecd-rootfs MP.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

Here is the updated MP to switch from the directory structure to a dot separated naming convention.

We ran the unit tests and verified several installation scenarii in a VM:
* Positive tests
  - Local single layer
  - Local multiple layers (up to 3)
  - Remote single layer
  - Remote multiple layers (up to 3)
* Negative tests
  - Local missing intermediary layer
  - Local empty file layer (0 byte)
  - Remote missing intermediary layer
  - Remote full local file system (cannot save to tmp directory)

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

This looks quite straight forward. Thanks.

I would like to see the parts of the main extract_root_* broken up into
seperate functions so we can add unittests for each of those portions.

Specifically:

I'd like to unittest the downloading of images (do we call download on the items provided by the path stack), what happens if one of the downloads fail, what happens when download works but file is empty.

I'd also like a unittest over the main extract_root_*;

review: Needs Fixing
Revision history for this message
Didier Roche-Tolomelli (didrocks) :
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

The function extract_root_layered_fsimage has been broken up into two parts to separate the download logic from the mount.

Several unittests have been added to cover positive and negative case for local and remote fs.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Chad Smith (chad.smith) wrote :

Thanks for this branch and the discussion. I've got a couple of suggestions and thoughts inline. Feel free to take what you will from this as I don't think they are really critical, but nice to have.

Revision history for this message
Chad Smith (chad.smith) :
Revision history for this message
Didier Roche-Tolomelli (didrocks) :
Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

@Chad, thanks for review.

We've updated the branch with most of your proposals:
- The download code if the layers of a stack image have been moved to its function.
- We've added retries and retry_delay argument to the url_helper.download and set it to 3 by default in existing download functions.
- There is now a test to cover download errors.
- unittests have been added to cover the new download helper functionality and verified the coverage.

Note that tests of the extract module have not been updated, as covering the public commands extract API already covers this submodule, and there isn’t much value in adding separated unittests.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Chad Smith (chad.smith) wrote :

Thanks for this rework, this looks good to me, minor nits inline.
+1

review: Approve
Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

Branch updated with a 3s delay.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

Thanks for splitting the logic and the extra unittests.

One minor change in the unittests suggested in-line comments
to remove duplicate setup code.

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

We originally tried to follow the coding style and conventions of the existing code even if it didn't seem to follow the best practices (no setup and copy'n paste)

However, the common test initialization code has been mode to setUp()

We cannot move add_patch to setUp() because 1) not all the tests must be patched (eg test_download_file_url) and 2) in test_download_file_url_retry_then_success we create a closure over the original object to restore it on second try.

Moreover, the MaaS tests use decorators and it's consistent with the download test.

Note that the MaaS tests should be factorized in the same manner by moving common code to either a function or in a setUp method.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :

I think we've never been closer to this MP being accepted :) Could someone please do a review of the last update and tell if there is still anything to fix?

Revision history for this message
Ryan Harper (raharper) wrote :
Revision history for this message
Ryan Harper (raharper) wrote :

146 was interrupted, restarted 147 completed, all but 4 tests passed, it looks transient, so I've started just those tests again:

tests/vmtests/test_bcache_bug1718699.py:XenialTestBcacheBug1718699 tests/vmtests/test_multipath.py:XenialGATestMultipathBasic tests/vmtests/test_network:CosmicTestNetworkBasic tests/vmtests/test_network_alias.py:CosmicTestNetworkAlias

We should be able to land today.

Revision history for this message
Jean-Baptiste Lallement (jibel) wrote :
Revision history for this message
Ryan Harper (raharper) :
review: Approve
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

Commit message lints:
 - Line #2 has 128 too many characters. Line starts with: "Adds type fsimage-layered"... - Line #4 has 89 too many characters. Line starts with: "If the final image is"...

review: Needs Fixing
Revision history for this message
Server Team CI bot (server-team-bot) :
review: Approve (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

Commit message lints:
 - Line #1 has 4 too many characters. Line starts with: " "...

review: Needs Fixing
Revision history for this message
Ryan Harper (raharper) wrote :

*sigh*

Revision history for this message
Server Team CI bot (server-team-bot) :
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/curtin/commands/extract.py b/curtin/commands/extract.py
index ec7a791..8567659 100644
--- a/curtin/commands/extract.py
+++ b/curtin/commands/extract.py
@@ -1,6 +1,7 @@
1# This file is part of curtin. See LICENSE file for copyright and license info.1# This file is part of curtin. See LICENSE file for copyright and license info.
22
3import os3import os
4import shutil
4import sys5import sys
5import tempfile6import tempfile
67
@@ -64,7 +65,7 @@ def extract_root_fsimage_url(url, target):
64 wfp = tempfile.NamedTemporaryFile(suffix=".img", delete=False)65 wfp = tempfile.NamedTemporaryFile(suffix=".img", delete=False)
65 wfp.close()66 wfp.close()
66 try:67 try:
67 url_helper.download(url, wfp.name)68 url_helper.download(url, wfp.name, retries=3)
68 return _extract_root_fsimage(wfp.name, target)69 return _extract_root_fsimage(wfp.name, target)
69 finally:70 finally:
70 os.unlink(wfp.name)71 os.unlink(wfp.name)
@@ -85,6 +86,121 @@ def _extract_root_fsimage(path, target):
85 os.rmdir(mp)86 os.rmdir(mp)
8687
8788
89def extract_root_layered_fsimage_url(uri, target):
90 ''' Build images list to consider from a layered structure
91
92 uri: URI of the layer file
93 target: Target file system to provision
94
95 return: None
96 '''
97 path = _path_from_file_url(uri)
98
99 image_stack = _get_image_stack(path)
100 LOG.debug("Considering fsimages: '%s'", ",".join(image_stack))
101
102 tmp_dir = None
103 try:
104 # Download every remote images if remote url
105 if url_helper.urlparse(path).scheme != "":
106 tmp_dir = tempfile.mkdtemp()
107 image_stack = _download_layered_images(image_stack, tmp_dir)
108
109 # Check that all images exists on disk and are not empty
110 for img in image_stack:
111 if not os.path.isfile(img) or os.path.getsize(img) <= 0:
112 raise ValueError("Failed to use fsimage: '%s' doesn't exist " +
113 "or is invalid", img)
114
115 return _extract_root_layered_fsimage(image_stack, target)
116 finally:
117 if tmp_dir and os.path.exists(tmp_dir):
118 shutil.rmtree(tmp_dir)
119
120
121def _download_layered_images(image_stack, tmp_dir):
122 local_image_stack = []
123 try:
124 for img_url in image_stack:
125 dest_path = os.path.join(tmp_dir,
126 os.path.basename(img_url))
127 url_helper.download(img_url, dest_path, retries=3)
128 local_image_stack.append(dest_path)
129 except url_helper.UrlError as e:
130 LOG.error("Failed to download '%s'" % img_url)
131 raise e
132 return local_image_stack
133
134
135def _extract_root_layered_fsimage(image_stack, target):
136 mp_base = tempfile.mkdtemp()
137 mps = []
138 try:
139 # Create a mount point for each image file and mount the image
140 try:
141 for img in image_stack:
142 mp = os.path.join(mp_base, os.path.basename(img) + ".dir")
143 os.mkdir(mp)
144 util.subp(['mount', '-o', 'loop,ro', img, mp], capture=True)
145 mps.insert(0, mp)
146 except util.ProcessExecutionError as e:
147 LOG.error("Failed to mount '%s' for extraction: %s", img, e)
148 raise e
149
150 # Prepare
151 if len(mps) == 1:
152 root_dir = mps[0]
153 else:
154 # Multiple image files, merge them with an overlay and do the copy
155 root_dir = os.path.join(mp_base, "root.dir")
156 os.mkdir(root_dir)
157 try:
158 util.subp(['mount', '-t', 'overlay', 'overlay', '-o',
159 'lowerdir=' + ':'.join(mps), root_dir],
160 capture=True)
161 mps.append(root_dir)
162 except util.ProcessExecutionError as e:
163 LOG.error("overlay mount to %s failed: %s", root_dir, e)
164 raise e
165
166 copy_to_target(root_dir, target)
167 finally:
168 umount_err_mps = []
169 for mp in reversed(mps):
170 try:
171 util.subp(['umount', mp], capture=True)
172 except util.ProcessExecutionError as e:
173 LOG.error("can't unmount %s: %e", mp, e)
174 umount_err_mps.append(mp)
175 if umount_err_mps:
176 raise util.ProcessExecutionError(
177 "Failed to umount: %s", ", ".join(umount_err_mps))
178 shutil.rmtree(mp_base)
179
180
181def _get_image_stack(uri):
182 '''Find a list of dependent images for given layered fsimage path
183
184 uri: URI of the layer file
185 return: tuple of path to dependent images
186 '''
187
188 image_stack = []
189 root_dir = os.path.dirname(uri)
190 img_name = os.path.basename(uri)
191 _, img_ext = os.path.splitext(img_name)
192
193 img_parts = img_name.split('.')
194 # Last item is the extension
195 for i in img_parts[:-1]:
196 image_stack.append(
197 os.path.join(
198 root_dir,
199 '.'.join(img_parts[0:img_parts.index(i)+1]) + img_ext))
200
201 return image_stack
202
203
88def copy_to_target(source, target):204def copy_to_target(source, target):
89 if source.startswith("cp://"):205 if source.startswith("cp://"):
90 source = source[5:]206 source = source[5:]
@@ -133,6 +249,8 @@ def extract(args):
133 copy_to_target(source['uri'], target)249 copy_to_target(source['uri'], target)
134 elif source['type'] == "fsimage":250 elif source['type'] == "fsimage":
135 extract_root_fsimage_url(source['uri'], target=target)251 extract_root_fsimage_url(source['uri'], target=target)
252 elif source['type'] == "fsimage-layered":
253 extract_root_layered_fsimage_url(source['uri'], target=target)
136 else:254 else:
137 extract_root_tgz_url(source['uri'], target=target)255 extract_root_tgz_url(source['uri'], target=target)
138256
diff --git a/curtin/commands/install.py b/curtin/commands/install.py
index 244683c..ad17508 100644
--- a/curtin/commands/install.py
+++ b/curtin/commands/install.py
@@ -408,10 +408,6 @@ def cmd_install(args):
408 if not len(cfg.get('sources', [])):408 if not len(cfg.get('sources', [])):
409 raise util.BadUsage("no sources provided to install")409 raise util.BadUsage("no sources provided to install")
410410
411 for i in cfg['sources']:
412 # we default to tgz for old style sources config
413 cfg['sources'][i] = util.sanitize_source(cfg['sources'][i])
414
415 migrate_proxy_settings(cfg)411 migrate_proxy_settings(cfg)
416 for k in ('http_proxy', 'https_proxy', 'no_proxy'):412 for k in ('http_proxy', 'https_proxy', 'no_proxy'):
417 if k in cfg['proxy']:413 if k in cfg['proxy']:
diff --git a/curtin/url_helper.py b/curtin/url_helper.py
index 767510d..ee4640b 100644
--- a/curtin/url_helper.py
+++ b/curtin/url_helper.py
@@ -82,39 +82,51 @@ class UrlReader(object):
82 self.close()82 self.close()
8383
8484
85def download(url, path, reporthook=None, data=None):85def download(url, path, reporthook=None, data=None, retries=0, retry_delay=3):
86 """Download url to path.86 """Download url to path.
8787
88 reporthook is compatible with py3 urllib.request.urlretrieve.88 reporthook is compatible with py3 urllib.request.urlretrieve.
89 urlretrieve does not exist in py2."""89 urlretrieve does not exist in py2."""
9090
91 buflen = 819291 buflen = 8192
92 wfp = open(path, "wb")92 attempts = 0
9393
94 try:94 while True:
95 buf = None95 wfp = open(path, "wb")
96 blocknum = 096 try:
97 fsize = 097 buf = None
98 start = time.time()98 blocknum = 0
99 with UrlReader(url) as rfp:99 fsize = 0
100 if reporthook:100 start = time.time()
101 reporthook(blocknum, buflen, rfp.size)101 with UrlReader(url) as rfp:
102
103 while True:
104 buf = rfp.read(buflen)
105 if not buf:
106 break
107 blocknum += 1
108 if reporthook:102 if reporthook:
109 reporthook(blocknum, buflen, rfp.size)103 reporthook(blocknum, buflen, rfp.size)
110 wfp.write(buf)104
111 fsize += len(buf)105 while True:
112 timedelta = time.time() - start106 buf = rfp.read(buflen)
113 LOG.debug("Downloaded %d bytes from %s to %s in %.2fs (%.2fMbps)",107 if not buf:
114 fsize, url, path, timedelta, fsize / timedelta / 1024 / 1024)108 break
115 return path, rfp.info109 blocknum += 1
116 finally:110 if reporthook:
117 wfp.close()111 reporthook(blocknum, buflen, rfp.size)
112 wfp.write(buf)
113 fsize += len(buf)
114 timedelta = time.time() - start
115 LOG.debug("Downloaded %d bytes from %s to %s in %.2fs (%.2fMbps)",
116 fsize, url, path, timedelta,
117 fsize / timedelta / 1024 / 1024)
118 return path, rfp.info
119 except UrlError as e:
120 # retry on internal server errors up to "retries #"
121 if (e.code < 500 or attempts >= retries):
122 raise e
123 LOG.debug("Current download failed with error: %s. Retrying in"
124 " %d seconds.",
125 e.code, retry_delay)
126 attempts += 1
127 time.sleep(retry_delay)
128 finally:
129 wfp.close()
118130
119131
120def get_maas_version(endpoint):132def get_maas_version(endpoint):
diff --git a/curtin/util.py b/curtin/util.py
index 35e004f..34d09a8 100644
--- a/curtin/util.py
+++ b/curtin/util.py
@@ -45,6 +45,10 @@ except NameError:
45from . import paths45from . import paths
46from .log import LOG, log_call46from .log import LOG, log_call
4747
48binary_type = bytes
49if sys.version_info[0] < 3:
50 binary_type = str
51
48_INSTALLED_HELPERS_PATH = 'usr/lib/curtin/helpers'52_INSTALLED_HELPERS_PATH = 'usr/lib/curtin/helpers'
49_INSTALLED_MAIN = 'usr/bin/curtin'53_INSTALLED_MAIN = 'usr/bin/curtin'
5054
@@ -891,7 +895,7 @@ def sanitize_source(source):
891 # already sanitized?895 # already sanitized?
892 return source896 return source
893 supported = ['tgz', 'dd-tgz', 'dd-tbz', 'dd-txz', 'dd-tar', 'dd-bz2',897 supported = ['tgz', 'dd-tgz', 'dd-tbz', 'dd-txz', 'dd-tar', 'dd-bz2',
894 'dd-gz', 'dd-xz', 'dd-raw', 'fsimage']898 'dd-gz', 'dd-xz', 'dd-raw', 'fsimage', 'fsimage-layered']
895 deftype = 'tgz'899 deftype = 'tgz'
896 for i in supported:900 for i in supported:
897 prefix = i + ":"901 prefix = i + ":"
diff --git a/doc/topics/config.rst b/doc/topics/config.rst
index 218bc17..bad8fc2 100644
--- a/doc/topics/config.rst
+++ b/doc/topics/config.rst
@@ -506,9 +506,83 @@ configures the method used to copy the image to the target system.
506- **cp://**: Use ``rsync`` command to copy source directory to target.506- **cp://**: Use ``rsync`` command to copy source directory to target.
507- **file://**: Use ``tar`` command to extract source to target.507- **file://**: Use ``tar`` command to extract source to target.
508- **http[s]://**: Use ``wget | tar`` commands to extract source to target.508- **http[s]://**: Use ``wget | tar`` commands to extract source to target.
509- **fsimage://**: mount filesystem image and copy contents to target.509- **fsimage://** mount filesystem image and copy contents to target.
510 Local file or url are supported. Filesystem can be any filesystem type510 Local file or url are supported. Filesystem can be any filesystem type
511 mountable by the running kernel.511 mountable by the running kernel.
512- **fsimage-layered://** mount layered filesystem image and copy contents to target.
513 A ``fsimage-layered`` install source is a string representing one or more mountable
514 images from a single local or remote directory. The string is dot-separated where
515 each value between the dots represents a particular image and the location of the
516 name within the string encodes the order in which it is to be mounted. The resulting
517 list of images are downloaded (if needed) then mounted and overlayed into a single
518 directory which is used as the source for installation.
519
520**Image Name Pattern**
521
522 [[<parent_layer>.]...]<layer name>.<file extension pattern>
523
524Example::
525
526 10-base.img
527 minimal.img
528 minimal.standard.live.squashfs
529 http://example.io/standard.squashfs
530
531**Layer Dependencies**
532
533Layers are parts of the name seperated by dots. Any layer in the name will
534be included as a dependency. The file extension pattern is used to find
535related layers.
536
537Examples:
538
539 Base use case::
540
541 /images
542 ├── main.squashfs
543 ├── main.upper.squashfs
544 └── main.upper.debug.squashfs
545
546 source='fsimage-layered://images/main.squashfs' -> images='/images/main.squashfs'
547 source='fsimage-layered://images/main.upper.squashfs' -> images='/images/main.upper.squashfs, /images/main.squashfs'
548 source='fsimage-layered://images/main.upper.debug.squashfs' -> images='/images/main.upper.debug.squashfs, /images/main.upper.squashfs, /images/main.squashfs'
549
550 Multiple extensions::
551
552 /images
553 ├── main.squashfs
554 ├── main.img
555 ├── main.upper.squashfs
556 ├── main.upper.img
557 └── main.upper.debug.squashfs
558
559 source='fsimage-layered://images/main.upper.squashfs' -> images='/images/main.upper.squashfs, /images/main.squashfs'
560 source='fsimage-layered://images/main.upper.img' -> images='/images/main.upper.img, /images/main.img'
561
562 Missing intermediary layer::
563
564 /images
565 ├── main.squashfs
566 └── main.upper.debug.squashfs
567
568If there is a missing image in the path to a leaf, an error will be raised
569
570 source='fsimage-layered://images/main.squashfs' -> images='/images/main.squashfs'
571 source='fsimage-layered://images/main.upper.debug.squashfs' -> Raised Error'
572
573 Remote Layers::
574
575 http://example.io/base.extended.debug.squashfs
576
577
578The URI passed to ``fsimage-layered`` may be on a remote system. Curtin
579will parse the URI and then download each layer from the remote system.
580This results in Curtin downloading the following URLs::
581
582- http://example.io/base.squashfs
583- http://example.io/base.extended.squashfs
584- http://example.io/base.extended.debug.squashfs
585
512586
513**Example Cloud-image**::587**Example Cloud-image**::
514588
diff --git a/tests/unittests/helpers.py b/tests/unittests/helpers.py
index 1268880..0963483 100644
--- a/tests/unittests/helpers.py
+++ b/tests/unittests/helpers.py
@@ -11,6 +11,8 @@ import string
11import tempfile11import tempfile
12from unittest import TestCase12from unittest import TestCase
1313
14from curtin import util
15
1416
15def builtin_module_name():17def builtin_module_name():
16 options = ('builtins', '__builtin__')18 options = ('builtins', '__builtin__')
@@ -87,4 +89,22 @@ def dir2dict(startdir, prefix=None):
87 flist[key] = fp.read()89 flist[key] = fp.read()
88 return flist90 return flist
8991
92
93def populate_dir(path, files):
94 if not os.path.exists(path):
95 os.makedirs(path)
96 ret = []
97 for (name, content) in files.items():
98 p = os.path.sep.join([path, name])
99 util.ensure_dir(os.path.dirname(p))
100 with open(p, "wb") as fp:
101 if isinstance(content, util.binary_type):
102 fp.write(content)
103 else:
104 fp.write(content.encode('utf-8'))
105 fp.close()
106 ret.append(p)
107
108 return ret
109
90# vi: ts=4 expandtab syntax=python110# vi: ts=4 expandtab syntax=python
diff --git a/tests/unittests/test_commands_extract.py b/tests/unittests/test_commands_extract.py
index cc117bb..c318699 100644
--- a/tests/unittests/test_commands_extract.py
+++ b/tests/unittests/test_commands_extract.py
@@ -4,12 +4,15 @@ import os
4from .helpers import CiTestCase4from .helpers import CiTestCase
55
6from curtin import util6from curtin import util
7from curtin.commands.extract import extract_root_fsimage_url7from curtin.commands.extract import (extract_root_fsimage_url,
8 extract_root_layered_fsimage_url,
9 _get_image_stack)
10from curtin.url_helper import UrlError
811
912
10class TestExtractRootFsImageUrl(CiTestCase):13class TestExtractRootFsImageUrl(CiTestCase):
11 """Test extract_root_fsimage_url."""14 """Test extract_root_fsimage_url."""
12 def _fake_download(self, url, path):15 def _fake_download(self, url, path, retries=0):
13 self.downloads.append(os.path.abspath(path))16 self.downloads.append(os.path.abspath(path))
14 with open(path, "w") as fp:17 with open(path, "w") as fp:
15 fp.write("fake content from " + url + "\n")18 fp.write("fake content from " + url + "\n")
@@ -43,7 +46,7 @@ class TestExtractRootFsImageUrl(CiTestCase):
43 target = self.tmp_path("target_d", tmpd)46 target = self.tmp_path("target_d", tmpd)
44 fpath = self.tmp_path("my.img", tmpd)47 fpath = self.tmp_path("my.img", tmpd)
45 util.write_file(fpath, fpath + " data\n")48 util.write_file(fpath, fpath + " data\n")
46 extract_root_fsimage_url("file://" + fpath, target)49 extract_root_fsimage_url("file:///" + fpath, target)
47 self.assertEqual(1, self.m__extract_root_fsimage.call_count)50 self.assertEqual(1, self.m__extract_root_fsimage.call_count)
48 self.assertEqual(0, self.m_download.call_count)51 self.assertEqual(0, self.m_download.call_count)
4952
@@ -70,4 +73,225 @@ class TestExtractRootFsImageUrl(CiTestCase):
70 self.assertEqual(0, self.m_download.call_count)73 self.assertEqual(0, self.m_download.call_count)
7174
7275
76class TestExtractRootLayeredFsImageUrl(CiTestCase):
77 """Test extract_root_layared_fsimage_url."""
78 def _fake_download(self, url, path, retries=0):
79 self.downloads.append(os.path.abspath(path))
80 with open(path, "w") as fp:
81 fp.write("fake content from " + url + "\n")
82
83 def setUp(self):
84 super(TestExtractRootLayeredFsImageUrl, self).setUp()
85 self.downloads = []
86 self.add_patch("curtin.commands.extract.url_helper.download",
87 "m_download", side_effect=self._fake_download)
88 self.add_patch("curtin.commands.extract._extract_root_layered_fsimage",
89 "m__extract_root_layered_fsimage")
90
91 def test_relative_local_file_single(self):
92 """extract_root_layered_fsimage_url supports relative file:// uris."""
93 tmpd = self.tmp_dir()
94 target = self.tmp_path("target_d", tmpd)
95 startdir = os.getcwd()
96 fname = "my.img"
97 try:
98 os.chdir(tmpd)
99 util.write_file(fname, fname + " data\n")
100 extract_root_layered_fsimage_url("file://" + fname, target)
101 finally:
102 os.chdir(startdir)
103 self.assertEqual(1, self.m__extract_root_layered_fsimage.call_count)
104 self.assertEqual(0, self.m_download.call_count)
105
106 def test_absolute_local_file_single(self):
107 """extract_root_layered_fsimage_url supports absolute file:/// uris."""
108 tmpd = self.tmp_dir()
109 target = self.tmp_path("target_d", tmpd)
110 fpath = self.tmp_path("my.img", tmpd)
111 util.write_file(fpath, fpath + " data\n")
112 extract_root_layered_fsimage_url("file:///" + fpath, target)
113 self.assertEqual(1, self.m__extract_root_layered_fsimage.call_count)
114 self.assertEqual(0, self.m_download.call_count)
115
116 def test_local_file_path_single(self):
117 """extract_root_layered_fsimage_url supports normal file path without
118 file:"""
119 tmpd = self.tmp_dir()
120 target = self.tmp_path("target_d", tmpd)
121 fpath = self.tmp_path("my.img", tmpd)
122 util.write_file(fpath, fpath + " data\n")
123 extract_root_layered_fsimage_url(os.path.abspath(fpath), target)
124 self.assertEqual(1, self.m__extract_root_layered_fsimage.call_count)
125 self.assertEqual(0, self.m_download.call_count)
126
127 def test_local_file_path_multiple(self):
128 """extract_root_layered_fsimage_url supports normal hierarchy file
129 path"""
130 tmpd = self.tmp_dir()
131 target = self.tmp_path("target_d", tmpd)
132 arg = os.path.abspath(self.tmp_path("minimal.standard.debug.squashfs",
133 tmpd))
134 for f in ["minimal.squashfs",
135 "minimal.standard.squashfs",
136 "minimal.standard.debug.squashfs"]:
137 fpath = self.tmp_path(f, tmpd)
138 util.write_file(fpath, fpath + " data\n")
139 extract_root_layered_fsimage_url(arg, target)
140 self.assertEqual(1, self.m__extract_root_layered_fsimage.call_count)
141 self.assertEqual(0, self.m_download.call_count)
142
143 def test_local_file_path_multiple_one_missing(self):
144 """extract_root_layered_fsimage_url supports normal hierarchy file
145 path but intermediate layer missing"""
146 tmpd = self.tmp_dir()
147 target = self.tmp_path("target_d", tmpd)
148 arg = os.path.abspath(self.tmp_path("minimal.standard.debug.squashfs",
149 tmpd))
150 for f in ["minimal.squashfs",
151 "minimal.standard.debug.squashfs"]:
152 fpath = self.tmp_path(f, tmpd)
153 util.write_file(fpath, fpath + " data\n")
154 self.assertRaises(ValueError, extract_root_layered_fsimage_url, arg,
155 target)
156 self.assertEqual(0, self.m__extract_root_layered_fsimage.call_count)
157 self.assertEqual(0, self.m_download.call_count)
158
159 def test_local_file_path_multiple_one_empty(self):
160 """extract_root_layered_fsimage_url supports normal hierarchy file
161 path but intermediate layer empty"""
162 tmpd = self.tmp_dir()
163 target = self.tmp_path("target_d", tmpd)
164 arg = os.path.abspath(self.tmp_path("minimal.standard.debug.squashfs",
165 tmpd))
166 for f in ["minimal.squashfs",
167 "minimal.standard.squashfs"
168 "minimal.standard.debug.squashfs"]:
169 fpath = self.tmp_path(f, tmpd)
170 if f == "minimal.standard.squashfs":
171 util.write_file(fpath, "")
172 else:
173 util.write_file(fpath, fpath + " data\n")
174 self.assertRaises(ValueError, extract_root_layered_fsimage_url, arg,
175 target)
176 self.assertEqual(0, self.m__extract_root_layered_fsimage.call_count)
177 self.assertEqual(0, self.m_download.call_count)
178
179 def test_remote_file_single(self):
180 """extract_root_layered_fsimage_url supports http:// urls."""
181 tmpd = self.tmp_dir()
182 target = self.tmp_path("target_d", tmpd)
183 myurl = "http://example.io/minimal.squashfs"
184 extract_root_layered_fsimage_url(myurl, target)
185 self.assertEqual(1, self.m__extract_root_layered_fsimage.call_count)
186 self.assertEqual(1, self.m_download.call_count)
187 self.assertEqual("http://example.io/minimal.squashfs",
188 self.m_download.call_args_list[0][0][0])
189 # ensure the file got cleaned up.
190 self.assertEqual([], [f for f in self.downloads if os.path.exists(f)])
191
192 def test_remote_file_multiple(self):
193 """extract_root_layered_fsimage_url supports normal hierarchy from
194 http:// urls."""
195 tmpd = self.tmp_dir()
196 target = self.tmp_path("target_d", tmpd)
197 myurl = "http://example.io/minimal.standard.debug.squashfs"
198 extract_root_layered_fsimage_url(myurl, target)
199 self.assertEqual(1, self.m__extract_root_layered_fsimage.call_count)
200 self.assertEqual(3, self.m_download.call_count)
201 for i, image_url in enumerate(["minimal.squashfs",
202 "minimal.standard.squashfs",
203 "minimal.standard.debug.squashfs"]):
204 self.assertEqual("http://example.io/" + image_url,
205 self.m_download.call_args_list[i][0][0])
206 # ensure the file got cleaned up.
207 self.assertEqual([], [f for f in self.downloads if os.path.exists(f)])
208
209 def test_remote_file_multiple_one_missing(self):
210 """extract_root_layered_fsimage_url supports normal hierarchy from
211 http:// urls with one layer missing."""
212
213 def fail_download_minimal_standard(url, path, retries=0):
214 if url == "http://example.io/minimal.standard.squashfs":
215 raise UrlError(url, 404, "Couldn't download",
216 None, None)
217 return self._fake_download(url, path, retries)
218 self.m_download.side_effect = fail_download_minimal_standard
219
220 tmpd = self.tmp_dir()
221 target = self.tmp_path("target_d", tmpd)
222 myurl = "http://example.io/minimal.standard.debug.squashfs"
223 self.assertRaises(UrlError, extract_root_layered_fsimage_url,
224 myurl, target)
225 self.assertEqual(0, self.m__extract_root_layered_fsimage.call_count)
226 self.assertEqual(2, self.m_download.call_count)
227 for i, image_url in enumerate(["minimal.squashfs",
228 "minimal.standard.squashfs"]):
229 self.assertEqual("http://example.io/" + image_url,
230 self.m_download.call_args_list[i][0][0])
231 # ensure the file got cleaned up.
232 self.assertEqual([], [f for f in self.downloads if os.path.exists(f)])
233
234 def test_remote_file_multiple_one_empty(self):
235 """extract_root_layered_fsimage_url supports normal hierarchy from
236 http:// urls with one layer empty."""
237
238 def empty_download_minimal_standard(url, path, retries=0):
239 if url == "http://example.io/minimal.standard.squashfs":
240 self.downloads.append(os.path.abspath(path))
241 with open(path, "w") as fp:
242 fp.write("")
243 return
244 return self._fake_download(url, path, retries)
245 self.m_download.side_effect = empty_download_minimal_standard
246
247 tmpd = self.tmp_dir()
248 target = self.tmp_path("target_d", tmpd)
249 myurl = "http://example.io/minimal.standard.debug.squashfs"
250 self.assertRaises(ValueError, extract_root_layered_fsimage_url,
251 myurl, target)
252 self.assertEqual(0, self.m__extract_root_layered_fsimage.call_count)
253 self.assertEqual(3, self.m_download.call_count)
254 for i, image_url in enumerate(["minimal.squashfs",
255 "minimal.standard.squashfs",
256 "minimal.standard.debug.squashfs"]):
257 self.assertEqual("http://example.io/" + image_url,
258 self.m_download.call_args_list[i][0][0])
259 # ensure the file got cleaned up.
260 self.assertEqual([], [f for f in self.downloads if os.path.exists(f)])
261
262
263class TestGetImageStack(CiTestCase):
264 """Test _get_image_stack."""
265
266 def test_get_image_stack(self):
267 """_get_image_paths returns a tuple of depending fsimages
268 with same extension"""
269 self.assertEqual(
270 ['/path/to/aa.fs',
271 '/path/to/aa.bbb.fs',
272 '/path/to/aa.bbb.cccc.fs'],
273 _get_image_stack("/path/to/aa.bbb.cccc.fs"))
274
275 def test_get_image_stack_none(self):
276 """_get_image_paths returns an empty tuple with no entry"""
277 self.assertEqual(
278 [],
279 _get_image_stack(""))
280
281 def test_get_image_stack_no_dependency(self):
282 """_get_image_paths returns a tuple a single element when fsimage
283 has no dependency"""
284 self.assertEqual(
285 ['/path/to/aa.fs'],
286 _get_image_stack("/path/to/aa.fs"))
287
288 def test_get_image_stack_with_urls(self):
289 """_get_image_paths returns a tuple of depending fsimages
290 with same extension and same urls"""
291 self.assertEqual(
292 ['https://path.com/to/aa.fs',
293 'https://path.com/to/aa.bbb.fs',
294 'https://path.com/to/aa.bbb.cccc.fs'],
295 _get_image_stack("https://path.com/to/aa.bbb.cccc.fs"))
296
73# vi: ts=4 expandtab syntax=python297# vi: ts=4 expandtab syntax=python
diff --git a/tests/unittests/test_url_helper.py b/tests/unittests/test_url_helper.py
index 9cc9a5d..1b550f6 100644
--- a/tests/unittests/test_url_helper.py
+++ b/tests/unittests/test_url_helper.py
@@ -10,19 +10,71 @@ from .helpers import CiTestCase
1010
1111
12class TestDownload(CiTestCase):12class TestDownload(CiTestCase):
13 def setUp(self):
14 super(TestDownload, self).setUp()
15 self.tmpd = self.tmp_dir()
16 self.src_file = self.tmp_path("my-source", self.tmpd)
17 with open(self.src_file, "wb") as fp:
18 # Write the min amount of bytes
19 fp.write(b':-)\n' * int(8200/4))
20 self.target_file = self.tmp_path("my-target", self.tmpd)
21
13 def test_download_file_url(self):22 def test_download_file_url(self):
14 """Download a file to another file."""23 """Download a file to another file."""
15 tmpd = self.tmp_dir()24 url_helper.download("file://" + self.src_file, self.target_file)
16 src_file = self.tmp_path("my-source", tmpd)25 self.assertTrue(filecmp.cmp(self.src_file, self.target_file),
17 target_file = self.tmp_path("my-target", tmpd)26 "Downloaded file differed from source file.")
27
28 @mock.patch('curtin.url_helper.UrlReader')
29 def test_download_file_url_retry(self, urlreader_mock):
30 """Retry downloading a file with server error (http 5xx)."""
31 urlreader_mock.side_effect = url_helper.UrlError(None, code=500)
32
33 self.assertRaises(url_helper.UrlError, url_helper.download,
34 "file://" + self.src_file, self.target_file,
35 retries=3, retry_delay=0)
36 self.assertEquals(4, urlreader_mock.call_count,
37 "Didn't call UrlReader 4 times (retries=3)")
38
39 @mock.patch('curtin.url_helper.UrlReader')
40 def test_download_file_url_no_retry(self, urlreader_mock):
41 """No retry by default on downloading a file with server error
42 (http 5xx)."""
43 urlreader_mock.side_effect = url_helper.UrlError(None, code=500)
44
45 self.assertRaises(url_helper.UrlError, url_helper.download,
46 "file://" + self.src_file, self.target_file,
47 retry_delay=0)
48 self.assertEquals(1, urlreader_mock.call_count,
49 "Didn't call UrlReader once (retries=0)")
50
51 @mock.patch('curtin.url_helper.UrlReader')
52 def test_download_file_url_no_retry_on_client_error(self, urlreader_mock):
53 """No retry by default on downloading a file with 4xx http error."""
54 urlreader_mock.side_effect = url_helper.UrlError(None, code=404)
55
56 self.assertRaises(url_helper.UrlError, url_helper.download,
57 "file://" + self.src_file, self.target_file,
58 retries=3, retry_delay=0)
59 self.assertEquals(1, urlreader_mock.call_count,
60 "Didn't call UrlReader once (400 class error)")
1861
19 # Make sure we have > 8192 bytes in the file (buflen of UrlReader)62 def test_download_file_url_retry_then_success(self):
20 with open(src_file, "wb") as fp:63 """Retry downloading a file with server error and then succeed."""
21 for line in range(0, 1024):64 url_reader = url_helper.UrlReader
22 fp.write(b'Who are the people in your neighborhood.\n')
2365
24 url_helper.download("file://" + src_file, target_file)66 with mock.patch('curtin.url_helper.UrlReader') as urlreader_mock:
25 self.assertTrue(filecmp.cmp(src_file, target_file),67 # return first an error, then, real object
68 def urlreader_download(url):
69 urlreader_mock.side_effect = url_reader
70 raise url_helper.UrlError(None, code=500)
71 urlreader_mock.side_effect = urlreader_download
72 url_helper.download("file://" + self.src_file, self.target_file,
73 retries=3, retry_delay=0)
74 self.assertEquals(2, urlreader_mock.call_count,
75 "Didn't call UrlReader twice (first failing,"
76 "then success)")
77 self.assertTrue(filecmp.cmp(self.src_file, self.target_file),
26 "Downloaded file differed from source file.")78 "Downloaded file differed from source file.")
2779
2880

Subscribers

People subscribed via source and target branches