#
# Copyright (C) 2017-2018  Red Hat, Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
""" Setup v0 of the API server

v0_api() must be called to setup the API routes for Flask

Status Responses
----------------

Some requests only return a status/error response.

  The response will be a status response with `status` set to true, or an
  error response with it set to false and an error message included.

  Example response::

      {
        "status": true
      }

  Error response::

      {
        "errors": ["ggit-error: Failed to remove entry. File isn't in the tree - jboss.toml (-1)"]
        "status": false
      }

API Routes
----------

All of the blueprints routes support the optional `branch` argument. If it is not
used then the API will use the `master` branch for blueprints. If you want to create
a new branch use the `new` or `workspace` routes with ?branch=<branch-name> to
store the new blueprint on the new branch.

`/api/v0/blueprints/list`
^^^^^^^^^^^^^^^^^^^^^^^^^

  List the available blueprints::

      { "limit": 20,
        "offset": 0,
        "blueprints": [
          "atlas",
          "development",
          "glusterfs",
          "http-server",
          "jboss",
          "kubernetes" ],
        "total": 6 }

`/api/v0/blueprints/info/<blueprint_names>[?format=<json|toml>]`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return the JSON representation of the blueprint. This includes 3 top level
  objects.  `changes` which lists whether or not the workspace is different from
  the most recent commit. `blueprints` which lists the JSON representation of the
  blueprint, and `errors` which will list any errors, like non-existant blueprints.

  By default the response is JSON, but if `?format=toml` is included in the URL's
  arguments it will return the response as the blueprint's raw TOML content.
  *Unless* there is an error which will only return a 400 and a standard error
  `Status Response`_.

  If there is an error when JSON is requested the successful blueprints and the
  errors will both be returned.

  Example of json response::

      {
        "changes": [
          {
            "changed": false,
            "name": "glusterfs"
          }
        ],
        "errors": [],
        "blueprints": [
          {
            "description": "An example GlusterFS server with samba",
            "modules": [
              {
                "name": "glusterfs",
                "version": "3.7.*"
              },
              {
                "name": "glusterfs-cli",
                "version": "3.7.*"
              }
            ],
            "name": "glusterfs",
            "packages": [
              {
                "name": "2ping",
                "version": "3.2.1"
              },
              {
                "name": "samba",
                "version": "4.2.*"
              }
            ],
            "version": "0.0.6"
          }
        ]
      }

  Error example::

      {
        "changes": [],
        "errors": ["ggit-error: the path 'missing.toml' does not exist in the given tree (-3)"]
        "blueprints": []
      }

`/api/v0/blueprints/changes/<blueprint_names>[?offset=0&limit=20]`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return the commits to a blueprint. By default it returns the first 20 commits, this
  can be changed by passing `offset` and/or `limit`. The response will include the
  commit hash, summary, timestamp, and optionally the revision number. The commit
  hash can be passed to `/api/v0/blueprints/diff/` to retrieve the exact changes.

  Example::

      {
        "errors": [],
        "limit": 20,
        "offset": 0,
        "blueprints": [
          {
            "changes": [
              {
                "commit": "e083921a7ed1cf2eec91ad12b9ad1e70ef3470be",
                "message": "blueprint glusterfs, version 0.0.6 saved.",
                "revision": null,
                "timestamp": "2017-11-23T00:18:13Z"
              },
              {
                "commit": "cee5f4c20fc33ea4d54bfecf56f4ad41ad15f4f3",
                "message": "blueprint glusterfs, version 0.0.5 saved.",
                "revision": null,
                "timestamp": "2017-11-11T01:00:28Z"
              },
              {
                "commit": "29b492f26ed35d80800b536623bafc51e2f0eff2",
                "message": "blueprint glusterfs, version 0.0.4 saved.",
                "revision": null,
                "timestamp": "2017-11-11T00:28:30Z"
              },
              {
                "commit": "03374adbf080fe34f5c6c29f2e49cc2b86958bf2",
                "message": "blueprint glusterfs, version 0.0.3 saved.",
                "revision": null,
                "timestamp": "2017-11-10T23:15:52Z"
              },
              {
                "commit": "0e08ecbb708675bfabc82952599a1712a843779d",
                "message": "blueprint glusterfs, version 0.0.2 saved.",
                "revision": null,
                "timestamp": "2017-11-10T23:14:56Z"
              },
              {
                "commit": "3e11eb87a63d289662cba4b1804a0947a6843379",
                "message": "blueprint glusterfs, version 0.0.1 saved.",
                "revision": null,
                "timestamp": "2017-11-08T00:02:47Z"
              }
            ],
            "name": "glusterfs",
            "total": 6
          }
        ]
      }

POST `/api/v0/blueprints/new`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Create a new blueprint, or update an existing blueprint. This supports both JSON and TOML
  for the blueprint format. The blueprint should be in the body of the request with the
  `Content-Type` header set to either `application/json` or `text/x-toml`.

  The response will be a status response with `status` set to true, or an
  error response with it set to false and an error message included.

DELETE `/api/v0/blueprints/delete/<blueprint_name>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Delete a blueprint. The blueprint is deleted from the branch, and will no longer
  be listed by the `list` route. A blueprint can be undeleted using the `undo` route
  to revert to a previous commit. This will also delete the workspace copy of the
  blueprint.

  The response will be a status response with `status` set to true, or an
  error response with it set to false and an error message included.

POST `/api/v0/blueprints/workspace`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Write a blueprint to the temporary workspace. This works exactly the same as `new` except
  that it does not create a commit. JSON and TOML bodies are supported.

  The workspace is meant to be used as a temporary blueprint storage for clients.
  It will be read by the `info` and `diff` routes if it is different from the
  most recent commit.

  The response will be a status response with `status` set to true, or an
  error response with it set to false and an error message included.

DELETE `/api/v0/blueprints/workspace/<blueprint_name>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Remove the temporary workspace copy of a blueprint. The `info` route will now
  return the most recent commit of the blueprint. Any changes that were in the
  workspace will be lost.

  The response will be a status response with `status` set to true, or an
  error response with it set to false and an error message included.

POST `/api/v0/blueprints/undo/<blueprint_name>/<commit>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  This will revert the blueprint to a previous commit. The commit hash from the `changes`
  route can be used in this request.

  The response will be a status response with `status` set to true, or an
  error response with it set to false and an error message included.

POST `/api/v0/blueprints/tag/<blueprint_name>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Tag a blueprint as a new release. This uses git tags with a special format.
  `refs/tags/<branch>/<filename>/r<revision>`. Only the most recent blueprint commit
  can be tagged. Revisions start at 1 and increment for each new tag
  (per-blueprint). If the commit has already been tagged it will return false.

  The response will be a status response with `status` set to true, or an
  error response with it set to false and an error message included.

`/api/v0/blueprints/diff/<blueprint_name>/<from_commit>/<to_commit>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return the differences between two commits, or the workspace. The commit hash
  from the `changes` response can be used here, or several special strings:

  - NEWEST will select the newest git commit. This works for `from_commit` or `to_commit`
  - WORKSPACE will select the workspace copy. This can only be used in `to_commit`

  eg. `/api/v0/blueprints/diff/glusterfs/NEWEST/WORKSPACE` will return the differences
  between the most recent git commit and the contents of the workspace.

  Each entry in the response's diff object contains the old blueprint value and the new one.
  If old is null and new is set, then it was added.
  If new is null and old is set, then it was removed.
  If both are set, then it was changed.

  The old/new entries will have the name of the blueprint field that was changed. This
  can be one of: Name, Description, Version, Module, or Package.
  The contents for these will be the old/new values for them.

  In the example below the version was changed and the ping package was added.

  Example::

      {
        "diff": [
          {
            "new": {
              "Version": "0.0.6"
            },
            "old": {
              "Version": "0.0.5"
            }
          },
          {
            "new": {
              "Package": {
                "name": "ping",
                "version": "3.2.1"
              }
            },
            "old": null
          }
        ]
      }

`/api/v0/blueprints/freeze/<blueprint_names>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return a JSON representation of the blueprint with the package and module versions set
  to the exact versions chosen by depsolving the blueprint.

  Example::

      {
        "errors": [],
        "blueprints": [
          {
            "blueprint": {
              "description": "An example GlusterFS server with samba",
              "modules": [
                {
                  "name": "glusterfs",
                  "version": "3.8.4-18.4.el7.x86_64"
                },
                {
                  "name": "glusterfs-cli",
                  "version": "3.8.4-18.4.el7.x86_64"
                }
              ],
              "name": "glusterfs",
              "packages": [
                {
                  "name": "ping",
                  "version": "2:3.2.1-2.el7.noarch"
                },
                {
                  "name": "samba",
                  "version": "4.6.2-8.el7.x86_64"
                }
              ],
              "version": "0.0.6"
            }
          }
        ]
      }

`/api/v0/blueprints/depsolve/<blueprint_names>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Depsolve the blueprint using yum, return the blueprint used, and the NEVRAs of the packages
  chosen to satisfy the blueprint's requirements. The response will include a list of results,
  with the full dependency list in `dependencies`, the NEVRAs for the blueprint's direct modules
  and packages in `modules`, and any error will be in `errors`.

  Example::

      {
        "errors": [],
        "blueprints": [
          {
            "dependencies": [
              {
                "arch": "noarch",
                "epoch": "0",
                "name": "2ping",
                "release": "2.el7",
                "version": "3.2.1"
              },
              {
                "arch": "x86_64",
                "epoch": "0",
                "name": "acl",
                "release": "12.el7",
                "version": "2.2.51"
              },
              {
                "arch": "x86_64",
                "epoch": "0",
                "name": "audit-libs",
                "release": "3.el7",
                "version": "2.7.6"
              },
              {
                "arch": "x86_64",
                "epoch": "0",
                "name": "avahi-libs",
                "release": "17.el7",
                "version": "0.6.31"
              },
              ...
            ],
            "modules": [
              {
                "arch": "noarch",
                "epoch": "0",
                "name": "2ping",
                "release": "2.el7",
                "version": "3.2.1"
              },
              {
                "arch": "x86_64",
                "epoch": "0",
                "name": "glusterfs",
                "release": "18.4.el7",
                "version": "3.8.4"
              },
              ...
            ],
            "blueprint": {
              "description": "An example GlusterFS server with samba",
              "modules": [
                {
                  "name": "glusterfs",
                  "version": "3.7.*"
                },
             ...
            }
          }
        ]
      }

`/api/v0/projects/list[?offset=0&limit=20]`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  List all of the available projects. By default this returns the first 20 items,
  but this can be changed by setting the `offset` and `limit` arguments.

  Example::

      {
        "limit": 20,
        "offset": 0,
        "projects": [
          {
            "description": "0 A.D. (pronounced \"zero ey-dee\") is a ...",
            "homepage": "http://play0ad.com",
            "name": "0ad",
            "summary": "Cross-Platform RTS Game of Ancient Warfare",
            "upstream_vcs": "UPSTREAM_VCS"
          },
          ...
        ],
        "total": 21770
      }

`/api/v0/projects/info/<project_names>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return information about the comma-separated list of projects. It includes the description
  of the package along with the list of available builds.

  Example::

      {
        "projects": [
          {
            "builds": [
              {
                "arch": "x86_64",
                "build_config_ref": "BUILD_CONFIG_REF",
                "build_env_ref": "BUILD_ENV_REF",
                "build_time": "2017-03-01T08:39:23",
                "changelog": "- restore incremental backups correctly, files ...",
                "epoch": "2",
                "metadata": {},
                "release": "32.el7",
                "source": {
                  "license": "GPLv3+",
                  "metadata": {},
                  "source_ref": "SOURCE_REF",
                  "version": "1.26"
                }
              }
            ],
            "description": "The GNU tar program saves many ...",
            "homepage": "http://www.gnu.org/software/tar/",
            "name": "tar",
            "summary": "A GNU file archiving program",
            "upstream_vcs": "UPSTREAM_VCS"
          }
        ]
      }

`/api/v0/projects/depsolve/<project_names>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Depsolve the comma-separated list of projects and return the list of NEVRAs needed
  to satisfy the request.

  Example::

      {
        "projects": [
          {
            "arch": "noarch",
            "epoch": "0",
            "name": "basesystem",
            "release": "7.el7",
            "version": "10.0"
          },
          {
            "arch": "x86_64",
            "epoch": "0",
            "name": "bash",
            "release": "28.el7",
            "version": "4.2.46"
          },
          {
            "arch": "x86_64",
            "epoch": "0",
            "name": "filesystem",
            "release": "21.el7",
            "version": "3.2"
          },
          ...
        ]
      }

`/api/v0/projects/source/list`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return the list of repositories used for depsolving and installing packages.

  Example::

      {
        "sources": [
          "fedora",
          "fedora-cisco-openh264",
          "fedora-updates-testing",
          "fedora-updates"
        ]
      }

`/api/v0/projects/source/info/<source-names>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return information about the comma-separated list of source names. Or all of the
  sources if '*' is passed. Note that general globbing is not supported, only '*'.

  immutable system sources will have the "system" field set to true. User added sources
  will have it set to false. System sources cannot be changed or deleted.

  Example::

      {
        "errors": [],
        "sources": {
          "fedora": {
            "check_gpg": true,
            "check_ssl": true,
            "gpgkey_urls": [
              "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-28-x86_64"
            ],
            "name": "fedora",
            "proxy": "http://proxy.brianlane.com:8123",
            "system": true,
            "type": "yum-metalink",
            "url": "https://mirrors.fedoraproject.org/metalink?repo=fedora-28&arch=x86_64"
          }
        }
      }

POST `/api/v0/projects/source/new`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Add (or change) a source for use when depsolving blueprints and composing images.

  The ``proxy`` and ``gpgkey_urls`` entries are optional. All of the others are required. The supported
  types for the urls are:

  * ``yum-baseurl`` is a URL to a yum repository.
  * ``yum-mirrorlist`` is a URL for a mirrorlist.
  * ``yum-metalink`` is a URL for a metalink.

  If ``check_ssl`` is true the https certificates must be valid. If they are self-signed you can either set
  this to false, or add your Certificate Authority to the host system.

  If ``check_gpg`` is true the GPG key must either be installed on the host system, or ``gpgkey_urls``
  should point to it.

  You can edit an existing source (other than system sources), by doing a POST
  of the new version of the source. It will overwrite the previous one.

  Example::

      {
          "name": "custom-source-1",
          "url": "https://url/path/to/repository/",
          "type": "yum-baseurl",
          "check_ssl": true,
          "check_gpg": true,
          "gpgkey_urls": [
              "https://url/path/to/gpg-key"
          ]
      }

DELETE `/api/v0/projects/source/delete/<source-name>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Delete a user added source. This will fail if a system source is passed to
  it.

  The response will be a status response with `status` set to true, or an
  error response with it set to false and an error message included.

`/api/v0/modules/list[?offset=0&limit=20]`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return a list of all of the available modules. This includes the name and the
  group_type, which is always "rpm" for lorax-composer. By default this returns
  the first 20 items. This can be changed by setting the `offset` and `limit`
  arguments.

  Example::

      {
        "limit": 20,
        "modules": [
          {
            "group_type": "rpm",
            "name": "0ad"
          },
          {
            "group_type": "rpm",
            "name": "0ad-data"
          },
          {
            "group_type": "rpm",
            "name": "0install"
          },
          {
            "group_type": "rpm",
            "name": "2048-cli"
          },
          ...
        ]
        "total": 21770
      }

`/api/v0/modules/list/<module_names>[?offset=0&limit=20]`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return the list of comma-separated modules. Output is the same as `/modules/list`

  Example::

      {
        "limit": 20,
        "modules": [
          {
            "group_type": "rpm",
            "name": "tar"
          }
        ],
        "offset": 0,
        "total": 1
      }

`/api/v0/modules/info/<module_names>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return the module's dependencies, and the information about the module.

  Example::

      {
        "modules": [
          {
            "dependencies": [
              {
                "arch": "noarch",
                "epoch": "0",
                "name": "basesystem",
                "release": "7.el7",
                "version": "10.0"
              },
              {
                "arch": "x86_64",
                "epoch": "0",
                "name": "bash",
                "release": "28.el7",
                "version": "4.2.46"
              },
              ...
            ],
            "description": "The GNU tar program saves ...",
            "homepage": "http://www.gnu.org/software/tar/",
            "name": "tar",
            "summary": "A GNU file archiving program",
            "upstream_vcs": "UPSTREAM_VCS"
          }
        ]
      }

POST `/api/v0/compose`
^^^^^^^^^^^^^^^^^^^^^^

  Start a compose. The content type should be 'application/json' and the body of the POST
  should look like this::

      {
        "blueprint_name": "http-server",
        "compose_type": "tar",
        "branch": "master"
      }

  Pass it the name of the blueprint, the type of output (from '/api/v0/compose/types'), and the
  blueprint branch to use. 'branch' is optional and will default to master. It will create a new
  build and add it to the queue. It returns the build uuid and a status if it succeeds::

      {
        "build_id": "e6fa6db4-9c81-4b70-870f-a697ca405cdf",
        "status": true
      }

`/api/v0/compose/types`
^^^^^^^^^^^^^^^^^^^^^^^

  Returns the list of supported output types that are valid for use with 'POST /api/v0/compose'

      {
        "types": [
          {
            "enabled": true,
            "name": "tar"
          }
        ]
      }

`/api/v0/compose/queue`
^^^^^^^^^^^^^^^^^^^^^^^

  Return the status of the build queue. It includes information about the builds waiting,
  and the build that is running.

  Example::

      {
        "new": [
          {
            "id": "45502a6d-06e8-48a5-a215-2b4174b3614b",
            "blueprint": "glusterfs",
            "queue_status": "WAITING",
            "job_created": 1517362647.4570868,
            "version": "0.0.6"
          },
          {
            "id": "6d292bd0-bec7-4825-8d7d-41ef9c3e4b73",
            "blueprint": "kubernetes",
            "queue_status": "WAITING",
            "job_created": 1517362659.0034983,
            "version": "0.0.1"
          }
        ],
        "run": [
          {
            "id": "745712b2-96db-44c0-8014-fe925c35e795",
            "blueprint": "glusterfs",
            "queue_status": "RUNNING",
            "job_created": 1517362633.7965999,
            "job_started": 1517362633.8001345,
            "version": "0.0.6"
          }
        ]
      }

`/api/v0/compose/finished`
^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return the details on all of the finished composes on the system.

  Example::

      {
        "finished": [
          {
            "id": "70b84195-9817-4b8a-af92-45e380f39894",
            "blueprint": "glusterfs",
            "queue_status": "FINISHED",
            "job_created": 1517351003.8210032,
            "job_started": 1517351003.8230415,
            "job_finished": 1517359234.1003145,
            "version": "0.0.6"
          },
          {
            "id": "e695affd-397f-4af9-9022-add2636e7459",
            "blueprint": "glusterfs",
            "queue_status": "FINISHED",
            "job_created": 1517362289.7193348,
            "job_started": 1517362289.9751132,
            "job_finished": 1517363500.1234567,
            "version": "0.0.6"
          }
        ]
      }

`/api/v0/compose/failed`
^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return the details on all of the failed composes on the system.

  Example::

      {
        "failed": [
           {
            "id": "8c8435ef-d6bd-4c68-9bf1-a2ef832e6b1a",
            "blueprint": "http-server",
            "queue_status": "FAILED",
            "job_created": 1517523249.9301329,
            "job_started": 1517523249.9314211,
            "job_finished": 1517523255.5623411,
            "version": "0.0.2"
          }
        ]
      }

`/api/v0/compose/status/<uuids>[?blueprint=<blueprint_name>&status=<compose_status>&type=<compose_type>]`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Return the details for each of the comma-separated list of uuids. A uuid of '*' will return
  details for all composes.

  Example::

      {
        "uuids": [
          {
            "id": "8c8435ef-d6bd-4c68-9bf1-a2ef832e6b1a",
            "blueprint": "http-server",
            "queue_status": "FINISHED",
            "job_created": 1517523644.2384307,
            "job_started": 1517523644.2551234,
            "job_finished": 1517523689.9864314,
            "version": "0.0.2"
          },
          {
            "id": "45502a6d-06e8-48a5-a215-2b4174b3614b",
            "blueprint": "glusterfs",
            "queue_status": "FINISHED",
            "job_created": 1517363442.188399,
            "job_started": 1517363442.325324,
            "job_finished": 1517363451.653621,
            "version": "0.0.6"
          }
        ]
      }

DELETE `/api/v0/compose/cancel/<uuid>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Cancel the build, if it is not finished, and delete the results. It will return a
  status of True if it is successful.

  Example::

      {
        "status": true,
        "uuid": "03397f8d-acff-4cdb-bd31-f629b7a948f5"
      }

DELETE `/api/v0/compose/delete/<uuids>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Delete the list of comma-separated uuids from the compose results.

  Example::

      {
        "errors": [],
        "uuids": [
          {
            "status": true,
            "uuid": "ae1bf7e3-7f16-4c9f-b36e-3726a1093fd0"
          }
        ]
      }

`/api/v0/compose/info/<uuid>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Get detailed information about the compose. The returned JSON string will
  contain the following information:

    * id - The uuid of the comoposition
    * config - containing the configuration settings used to run Anaconda
    * blueprint - The depsolved blueprint used to generate the kickstart
    * commit - The (local) git commit hash for the blueprint used
    * deps - The NEVRA of all of the dependencies used in the composition
    * compose_type - The type of output generated (tar, iso, etc.)
    * queue_status - The final status of the composition (FINISHED or FAILED)

  Example::

      {
        "commit": "7078e521a54b12eae31c3fd028680da7a0815a4d",
        "compose_type": "tar",
        "config": {
          "anaconda_args": "",
          "armplatform": "",
          "compress_args": [],
          "compression": "xz",
          "image_name": "root.tar.xz",
          ...
        },
        "deps": {
          "packages": [
            {
              "arch": "x86_64",
              "epoch": "0",
              "name": "acl",
              "release": "14.el7",
              "version": "2.2.51"
            }
          ]
        },
        "id": "c30b7d80-523b-4a23-ad52-61b799739ce8",
        "queue_status": "FINISHED",
        "blueprint": {
          "description": "An example kubernetes master",
          ...
        }
      }

`/api/v0/compose/metadata/<uuid>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Returns a .tar of the metadata used for the build. This includes all the
  information needed to reproduce the build, including the final kickstart
  populated with repository and package NEVRA.

  The mime type is set to 'application/x-tar' and the filename is set to
  UUID-metadata.tar

  The .tar is uncompressed, but is not large.

`/api/v0/compose/results/<uuid>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Returns a .tar of the metadata, logs, and output image of the build. This
  includes all the information needed to reproduce the build, including the
  final kickstart populated with repository and package NEVRA. The output image
  is already in compressed form so the returned tar is not compressed.

  The mime type is set to 'application/x-tar' and the filename is set to
  UUID.tar

`/api/v0/compose/logs/<uuid>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Returns a .tar of the anaconda build logs. The tar is not compressed, but is
  not large.

  The mime type is set to 'application/x-tar' and the filename is set to
  UUID-logs.tar

`/api/v0/compose/image/<uuid>`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Returns the output image from the build. The filename is set to the filename
  from the build with the UUID as a prefix. eg. UUID-root.tar.xz or UUID-boot.iso.

`/api/v0/compose/log/<uuid>[?size=kbytes]`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  Returns the end of the anaconda.log. The size parameter is optional and defaults to 1Mbytes
  if it is not included. The returned data is raw text from the end of the logfile, starting on
  a line boundry.

  Example::

      12:59:24,222 INFO anaconda: Running Thread: AnaConfigurationThread (140629395244800)
      12:59:24,223 INFO anaconda: Configuring installed system
      12:59:24,912 INFO anaconda: Configuring installed system
      12:59:24,912 INFO anaconda: Creating users
      12:59:24,913 INFO anaconda: Clearing libuser.conf at /tmp/libuser.Dyy8Gj
      12:59:25,154 INFO anaconda: Creating users
      12:59:25,155 INFO anaconda: Configuring addons
      12:59:25,155 INFO anaconda: Configuring addons
      12:59:25,155 INFO anaconda: Generating initramfs
      12:59:49,467 INFO anaconda: Generating initramfs
      12:59:49,467 INFO anaconda: Running post-installation scripts
      12:59:49,467 INFO anaconda: Running kickstart %%post script(s)
      12:59:50,782 INFO anaconda: All kickstart %%post script(s) have been run
      12:59:50,782 INFO anaconda: Running post-installation scripts
      12:59:50,784 INFO anaconda: Thread Done: AnaConfigurationThread (140629395244800)

"""

import logging
log = logging.getLogger("lorax-composer")

import os
from flask import jsonify, request, Response, send_file
import pytoml as toml

from pylorax.sysutils import joinpaths
from pylorax.api.checkparams import checkparams
from pylorax.api.compose import start_build, compose_types
from pylorax.api.crossdomain import crossdomain
from pylorax.api.errors import *                               # pylint: disable=wildcard-import
from pylorax.api.projects import projects_list, projects_info, projects_depsolve
from pylorax.api.projects import modules_list, modules_info, ProjectsError, repo_to_source
from pylorax.api.projects import get_repo_sources, delete_repo_source, source_to_repo, dnf_repo_to_file_repo
from pylorax.api.queue import queue_status, build_status, uuid_delete, uuid_status, uuid_info
from pylorax.api.queue import uuid_tar, uuid_image, uuid_cancel, uuid_log
from pylorax.api.recipes import RecipeError, list_branch_files, read_recipe_commit, recipe_filename, list_commits
from pylorax.api.recipes import recipe_from_dict, recipe_from_toml, commit_recipe, delete_recipe, revert_recipe
from pylorax.api.recipes import tag_recipe_commit, recipe_diff, RecipeFileError
from pylorax.api.regexes import VALID_API_STRING
from pylorax.api.workspace import workspace_read, workspace_write, workspace_delete

# The API functions don't actually get called by any code here
# pylint: disable=unused-variable

def take_limits(iterable, offset, limit):
    """ Apply offset and limit to an iterable object

    :param iterable: The object to limit
    :type iterable: iter
    :param offset: The number of items to skip
    :type offset: int
    :param limit: The total number of items to return
    :type limit: int
    :returns: A subset of the iterable
    """
    return iterable[offset:][:limit]

def blueprint_exists(api, branch, blueprint_name):
    """Return True if the blueprint exists

    :param api: flask object
    :type api: Flask
    :param branch: Branch name
    :type branch: str
    :param recipe_name: Recipe name to read
    :type recipe_name: str
    """
    try:
        with api.config["GITLOCK"].lock:
            read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name)

        return True
    except (RecipeError, RecipeFileError):
        return False

def v0_api(api):
    # Note that Sphinx will not generate documentations for any of these.
    @api.route("/api/v0/blueprints/list")
    @crossdomain(origin="*")
    def v0_blueprints_list():
        """List the available blueprints on a branch."""
        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        try:
            limit = int(request.args.get("limit", "20"))
            offset = int(request.args.get("offset", "0"))
        except ValueError as e:
            return jsonify(status=False, errors=[{"id": BAD_LIMIT_OR_OFFSET, "msg": str(e)}]), 400

        with api.config["GITLOCK"].lock:
            blueprints = [f[:-5] for f in list_branch_files(api.config["GITLOCK"].repo, branch)]
            limited_blueprints = take_limits(blueprints, offset, limit)
        return jsonify(blueprints=limited_blueprints, limit=limit, offset=offset, total=len(blueprints))

    @api.route("/api/v0/blueprints/info", defaults={'blueprint_names': ""})
    @api.route("/api/v0/blueprints/info/<blueprint_names>")
    @crossdomain(origin="*")
    @checkparams([("blueprint_names", "", "no blueprint names given")])
    def v0_blueprints_info(blueprint_names):
        """Return the contents of the blueprint, or a list of blueprints"""
        if VALID_API_STRING.match(blueprint_names) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        out_fmt = request.args.get("format", "json")
        if VALID_API_STRING.match(out_fmt) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in format argument"}]), 400

        blueprints = []
        changes = []
        errors = []
        for blueprint_name in [n.strip() for n in blueprint_names.split(",")]:
            exceptions = []
            # Get the workspace version (if it exists)
            try:
                with api.config["GITLOCK"].lock:
                    ws_blueprint = workspace_read(api.config["GITLOCK"].repo, branch, blueprint_name)
            except Exception as e:
                ws_blueprint = None
                exceptions.append(str(e))
                log.error("(v0_blueprints_info) %s", str(e))

            # Get the git version (if it exists)
            try:
                with api.config["GITLOCK"].lock:
                    git_blueprint = read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name)
            except RecipeFileError as e:
                # Adding an exception would be redundant, skip it
                git_blueprint = None
                log.error("(v0_blueprints_info) %s", str(e))
            except Exception as e:
                git_blueprint = None
                exceptions.append(str(e))
                log.error("(v0_blueprints_info) %s", str(e))

            if not ws_blueprint and not git_blueprint:
                # Neither blueprint, return an error
                errors.append({"id": UNKNOWN_BLUEPRINT, "msg": "%s: %s" % (blueprint_name, ", ".join(exceptions))})
            elif ws_blueprint and not git_blueprint:
                # No git blueprint, return the workspace blueprint
                changes.append({"name":blueprint_name, "changed":True})
                blueprints.append(ws_blueprint)
            elif not ws_blueprint and git_blueprint:
                # No workspace blueprint, no change, return the git blueprint
                changes.append({"name":blueprint_name, "changed":False})
                blueprints.append(git_blueprint)
            else:
                # Both exist, maybe changed, return the workspace blueprint
                changes.append({"name":blueprint_name, "changed":ws_blueprint != git_blueprint})
                blueprints.append(ws_blueprint)

        # Sort all the results by case-insensitive blueprint name
        changes = sorted(changes, key=lambda c: c["name"].lower())
        blueprints = sorted(blueprints, key=lambda r: r["name"].lower())

        if out_fmt == "toml":
            if errors:
                # If there are errors they need to be reported, use JSON and 400 for this
                return jsonify(status=False, errors=errors), 400
            else:
                # With TOML output we just want to dump the raw blueprint, skipping the rest.
                return "\n\n".join([r.toml() for r in blueprints])
        else:
            return jsonify(changes=changes, blueprints=blueprints, errors=errors)

    @api.route("/api/v0/blueprints/changes", defaults={'blueprint_names': ""})
    @api.route("/api/v0/blueprints/changes/<blueprint_names>")
    @crossdomain(origin="*")
    @checkparams([("blueprint_names", "", "no blueprint names given")])
    def v0_blueprints_changes(blueprint_names):
        """Return the changes to a blueprint or list of blueprints"""
        if VALID_API_STRING.match(blueprint_names) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        try:
            limit = int(request.args.get("limit", "20"))
            offset = int(request.args.get("offset", "0"))
        except ValueError as e:
            return jsonify(status=False, errors=[{"id": BAD_LIMIT_OR_OFFSET, "msg": str(e)}]), 400

        blueprints = []
        errors = []
        for blueprint_name in [n.strip() for n in blueprint_names.split(",")]:
            filename = recipe_filename(blueprint_name)
            try:
                with api.config["GITLOCK"].lock:
                    commits = list_commits(api.config["GITLOCK"].repo, branch, filename)
            except Exception as e:
                errors.append({"id": BLUEPRINTS_ERROR, "msg": "%s: %s" % (blueprint_name, str(e))})
                log.error("(v0_blueprints_changes) %s", str(e))
            else:
                if commits:
                    limited_commits = take_limits(commits, offset, limit)
                    blueprints.append({"name":blueprint_name, "changes":limited_commits, "total":len(commits)})
                else:
                    # no commits means there is no blueprint in the branch
                    errors.append({"id": UNKNOWN_BLUEPRINT, "msg": "%s" % blueprint_name})

        blueprints = sorted(blueprints, key=lambda r: r["name"].lower())

        return jsonify(blueprints=blueprints, errors=errors, offset=offset, limit=limit)

    @api.route("/api/v0/blueprints/new", methods=["POST"])
    @crossdomain(origin="*")
    def v0_blueprints_new():
        """Commit a new blueprint"""
        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        try:
            if request.headers['Content-Type'] == "text/x-toml":
                blueprint = recipe_from_toml(request.data)
            else:
                blueprint = recipe_from_dict(request.get_json(cache=False))

            if VALID_API_STRING.match(blueprint["name"]) is None:
                return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

            with api.config["GITLOCK"].lock:
                commit_recipe(api.config["GITLOCK"].repo, branch, blueprint)

                # Read the blueprint with new version and write it to the workspace
                blueprint = read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint["name"])
                workspace_write(api.config["GITLOCK"].repo, branch, blueprint)
        except Exception as e:
            log.error("(v0_blueprints_new) %s", str(e))
            return jsonify(status=False, errors=[{"id": BLUEPRINTS_ERROR, "msg": str(e)}]), 400
        else:
            return jsonify(status=True)

    @api.route("/api/v0/blueprints/delete", defaults={'blueprint_name': ""}, methods=["DELETE"])
    @api.route("/api/v0/blueprints/delete/<blueprint_name>", methods=["DELETE"])
    @crossdomain(origin="*")
    @checkparams([("blueprint_name", "", "no blueprint name given")])
    def v0_blueprints_delete(blueprint_name):
        """Delete a blueprint from git"""
        if VALID_API_STRING.match(blueprint_name) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        try:
            with api.config["GITLOCK"].lock:
                workspace_delete(api.config["GITLOCK"].repo, branch, blueprint_name)
                delete_recipe(api.config["GITLOCK"].repo, branch, blueprint_name)
        except Exception as e:
            log.error("(v0_blueprints_delete) %s", str(e))
            return jsonify(status=False, errors=[{"id": BLUEPRINTS_ERROR, "msg": str(e)}]), 400
        else:
            return jsonify(status=True)

    @api.route("/api/v0/blueprints/workspace", methods=["POST"])
    @crossdomain(origin="*")
    def v0_blueprints_workspace():
        """Write a blueprint to the workspace"""
        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        try:
            if request.headers['Content-Type'] == "text/x-toml":
                blueprint = recipe_from_toml(request.data)
            else:
                blueprint = recipe_from_dict(request.get_json(cache=False))

            if VALID_API_STRING.match(blueprint["name"]) is None:
                return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

            with api.config["GITLOCK"].lock:
                workspace_write(api.config["GITLOCK"].repo, branch, blueprint)
        except Exception as e:
            log.error("(v0_blueprints_workspace) %s", str(e))
            return jsonify(status=False, errors=[{"id": BLUEPRINTS_ERROR, "msg": str(e)}]), 400
        else:
            return jsonify(status=True)

    @api.route("/api/v0/blueprints/workspace", defaults={'blueprint_name': ""}, methods=["DELETE"])
    @api.route("/api/v0/blueprints/workspace/<blueprint_name>", methods=["DELETE"])
    @crossdomain(origin="*")
    @checkparams([("blueprint_name", "", "no blueprint name given")])
    def v0_blueprints_delete_workspace(blueprint_name):
        """Delete a blueprint from the workspace"""
        if VALID_API_STRING.match(blueprint_name) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        try:
            with api.config["GITLOCK"].lock:
                workspace_delete(api.config["GITLOCK"].repo, branch, blueprint_name)
        except Exception as e:
            log.error("(v0_blueprints_delete_workspace) %s", str(e))
            return jsonify(status=False, errors=[{"id": BLUEPRINTS_ERROR, "msg": str(e)}]), 400
        else:
            return jsonify(status=True)

    @api.route("/api/v0/blueprints/undo", defaults={'blueprint_name': "", 'commit': ""}, methods=["POST"])
    @api.route("/api/v0/blueprints/undo/<blueprint_name>", defaults={'commit': ""}, methods=["POST"])
    @api.route("/api/v0/blueprints/undo/<blueprint_name>/<commit>", methods=["POST"])
    @crossdomain(origin="*")
    @checkparams([("blueprint_name", "", "no blueprint name given"),
                  ("commit", "", "no commit ID given")])
    def v0_blueprints_undo(blueprint_name, commit):
        """Undo changes to a blueprint by reverting to a previous commit."""
        if VALID_API_STRING.match(blueprint_name) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        try:
            with api.config["GITLOCK"].lock:
                revert_recipe(api.config["GITLOCK"].repo, branch, blueprint_name, commit)

                # Read the new recipe and write it to the workspace
                blueprint = read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name)
                workspace_write(api.config["GITLOCK"].repo, branch, blueprint)
        except Exception as e:
            log.error("(v0_blueprints_undo) %s", str(e))
            return jsonify(status=False, errors=[{"id": UNKNOWN_COMMIT, "msg": str(e)}]), 400
        else:
            return jsonify(status=True)

    @api.route("/api/v0/blueprints/tag", defaults={'blueprint_name': ""}, methods=["POST"])
    @api.route("/api/v0/blueprints/tag/<blueprint_name>", methods=["POST"])
    @crossdomain(origin="*")
    @checkparams([("blueprint_name", "", "no blueprint name given")])
    def v0_blueprints_tag(blueprint_name):
        """Tag a blueprint's latest blueprint commit as a 'revision'"""
        if VALID_API_STRING.match(blueprint_name) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        try:
            with api.config["GITLOCK"].lock:
                tag_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name)
        except RecipeFileError as e:
            log.error("(v0_blueprints_tag) %s", str(e))
            return jsonify(status=False, errors=[{"id": UNKNOWN_BLUEPRINT, "msg": str(e)}]), 400
        except Exception as e:
            log.error("(v0_blueprints_tag) %s", str(e))
            return jsonify(status=False, errors=[{"id": BLUEPRINTS_ERROR, "msg": str(e)}]), 400
        else:
            return jsonify(status=True)

    @api.route("/api/v0/blueprints/diff", defaults={'blueprint_name': "", 'from_commit': "", 'to_commit': ""})
    @api.route("/api/v0/blueprints/diff/<blueprint_name>", defaults={'from_commit': "", 'to_commit': ""})
    @api.route("/api/v0/blueprints/diff/<blueprint_name>/<from_commit>", defaults={'to_commit': ""})
    @api.route("/api/v0/blueprints/diff/<blueprint_name>/<from_commit>/<to_commit>")
    @crossdomain(origin="*")
    @checkparams([("blueprint_name", "", "no blueprint name given"),
                  ("from_commit", "", "no from commit ID given"),
                  ("to_commit", "", "no to commit ID given")])
    def v0_blueprints_diff(blueprint_name, from_commit, to_commit):
        """Return the differences between two commits of a blueprint"""
        for s in [blueprint_name, from_commit, to_commit]:
            if VALID_API_STRING.match(s) is None:
                return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        if not blueprint_exists(api, branch, blueprint_name):
            return jsonify(status=False, errors=[{"id": UNKNOWN_BLUEPRINT, "msg": "Unknown blueprint name: %s" % blueprint_name}])

        try:
            if from_commit == "NEWEST":
                with api.config["GITLOCK"].lock:
                    old_blueprint = read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name)
            else:
                with api.config["GITLOCK"].lock:
                    old_blueprint = read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name, from_commit)
        except Exception as e:
            log.error("(v0_blueprints_diff) %s", str(e))
            return jsonify(status=False, errors=[{"id": UNKNOWN_COMMIT, "msg": str(e)}]), 400

        try:
            if to_commit == "WORKSPACE":
                with api.config["GITLOCK"].lock:
                    new_blueprint = workspace_read(api.config["GITLOCK"].repo, branch, blueprint_name)
                # If there is no workspace, use the newest commit instead
                if not new_blueprint:
                    with api.config["GITLOCK"].lock:
                        new_blueprint = read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name)
            elif to_commit == "NEWEST":
                with api.config["GITLOCK"].lock:
                    new_blueprint = read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name)
            else:
                with api.config["GITLOCK"].lock:
                    new_blueprint = read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name, to_commit)
        except Exception as e:
            log.error("(v0_blueprints_diff) %s", str(e))
            return jsonify(status=False, errors=[{"id": UNKNOWN_COMMIT, "msg": str(e)}]), 400

        diff = recipe_diff(old_blueprint, new_blueprint)
        return jsonify(diff=diff)

    @api.route("/api/v0/blueprints/freeze", defaults={'blueprint_names': ""})
    @api.route("/api/v0/blueprints/freeze/<blueprint_names>")
    @crossdomain(origin="*")
    @checkparams([("blueprint_names", "", "no blueprint names given")])
    def v0_blueprints_freeze(blueprint_names):
        """Return the blueprint with the exact modules and packages selected by depsolve"""
        if VALID_API_STRING.match(blueprint_names) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        out_fmt = request.args.get("format", "json")
        if VALID_API_STRING.match(out_fmt) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in format argument"}]), 400

        blueprints = []
        errors = []
        for blueprint_name in [n.strip() for n in sorted(blueprint_names.split(","), key=lambda n: n.lower())]:
            # get the blueprint
            # Get the workspace version (if it exists)
            blueprint = None
            try:
                with api.config["GITLOCK"].lock:
                    blueprint = workspace_read(api.config["GITLOCK"].repo, branch, blueprint_name)
            except Exception:
                pass

            if not blueprint:
                # No workspace version, get the git version (if it exists)
                try:
                    with api.config["GITLOCK"].lock:
                        blueprint = read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name)
                except RecipeFileError as e:
                    # adding an error here would be redundant, skip it
                    log.error("(v0_blueprints_freeze) %s", str(e))
                except Exception as e:
                    errors.append({"id": BLUEPRINTS_ERROR, "msg": "%s: %s" % (blueprint_name, str(e))})
                    log.error("(v0_blueprints_freeze) %s", str(e))

            # No blueprint found, skip it.
            if not blueprint:
                errors.append({"id": UNKNOWN_BLUEPRINT, "msg": "%s: blueprint_not_found" % blueprint_name})
                continue

            # Combine modules and packages and depsolve the list
            # TODO include the version/glob in the depsolving
            module_nver = blueprint.module_nver
            package_nver = blueprint.package_nver
            projects = sorted(set(module_nver+package_nver), key=lambda p: p[0].lower())
            deps = []
            try:
                with api.config["DNFLOCK"].lock:
                    deps = projects_depsolve(api.config["DNFLOCK"].dbo, projects, blueprint.group_names)
            except ProjectsError as e:
                errors.append({"id": BLUEPRINTS_ERROR, "msg": "%s: %s" % (blueprint_name, str(e))})
                log.error("(v0_blueprints_freeze) %s", str(e))

            blueprints.append({"blueprint": blueprint.freeze(deps)})

        if out_fmt == "toml":
            # With TOML output we just want to dump the raw blueprint, skipping the rest.
            return "\n\n".join([e["blueprint"].toml() for e in blueprints])
        else:
            return jsonify(blueprints=blueprints, errors=errors)

    @api.route("/api/v0/blueprints/depsolve", defaults={'blueprint_names': ""})
    @api.route("/api/v0/blueprints/depsolve/<blueprint_names>")
    @crossdomain(origin="*")
    @checkparams([("blueprint_names", "", "no blueprint names given")])
    def v0_blueprints_depsolve(blueprint_names):
        """Return the dependencies for a blueprint"""
        if VALID_API_STRING.match(blueprint_names) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        branch = request.args.get("branch", "master")
        if VALID_API_STRING.match(branch) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in branch argument"}]), 400

        blueprints = []
        errors = []
        for blueprint_name in [n.strip() for n in sorted(blueprint_names.split(","), key=lambda n: n.lower())]:
            # get the blueprint
            # Get the workspace version (if it exists)
            blueprint = None
            try:
                with api.config["GITLOCK"].lock:
                    blueprint = workspace_read(api.config["GITLOCK"].repo, branch, blueprint_name)
            except Exception:
                pass

            if not blueprint:
                # No workspace version, get the git version (if it exists)
                try:
                    with api.config["GITLOCK"].lock:
                        blueprint = read_recipe_commit(api.config["GITLOCK"].repo, branch, blueprint_name)
                except RecipeFileError as e:
                    # adding an error here would be redundant, skip it
                    log.error("(v0_blueprints_depsolve) %s", str(e))
                except Exception as e:
                    errors.append({"id": BLUEPRINTS_ERROR, "msg": "%s: %s" % (blueprint_name, str(e))})
                    log.error("(v0_blueprints_depsolve) %s", str(e))

            # No blueprint found, skip it.
            if not blueprint:
                errors.append({"id": UNKNOWN_BLUEPRINT, "msg": "%s: blueprint not found" % blueprint_name})
                continue

            # Combine modules and packages and depsolve the list
            # TODO include the version/glob in the depsolving
            module_nver = blueprint.module_nver
            package_nver = blueprint.package_nver
            projects = sorted(set(module_nver+package_nver), key=lambda p: p[0].lower())
            deps = []
            try:
                with api.config["DNFLOCK"].lock:
                    deps = projects_depsolve(api.config["DNFLOCK"].dbo, projects, blueprint.group_names)
            except ProjectsError as e:
                errors.append({"id": BLUEPRINTS_ERROR, "msg": "%s: %s" % (blueprint_name, str(e))})
                log.error("(v0_blueprints_depsolve) %s", str(e))

            # Get the NEVRA's of the modules and projects, add as "modules"
            modules = []
            for dep in deps:
                if dep["name"] in projects:
                    modules.append(dep)
            modules = sorted(modules, key=lambda m: m["name"].lower())

            blueprints.append({"blueprint":blueprint, "dependencies":deps, "modules":modules})

        return jsonify(blueprints=blueprints, errors=errors)

    @api.route("/api/v0/projects/list")
    @crossdomain(origin="*")
    def v0_projects_list():
        """List all of the available projects/packages"""
        try:
            limit = int(request.args.get("limit", "20"))
            offset = int(request.args.get("offset", "0"))
        except ValueError as e:
            return jsonify(status=False, errors=[{"id": BAD_LIMIT_OR_OFFSET, "msg": str(e)}]), 400

        try:
            with api.config["DNFLOCK"].lock:
                available = projects_list(api.config["DNFLOCK"].dbo)
        except ProjectsError as e:
            log.error("(v0_projects_list) %s", str(e))
            return jsonify(status=False, errors=[{"id": PROJECTS_ERROR, "msg": str(e)}]), 400

        projects = take_limits(available, offset, limit)
        return jsonify(projects=projects, offset=offset, limit=limit, total=len(available))

    @api.route("/api/v0/projects/info", defaults={'project_names': ""})
    @api.route("/api/v0/projects/info/<project_names>")
    @crossdomain(origin="*")
    @checkparams([("project_names", "", "no project names given")])
    def v0_projects_info(project_names):
        """Return detailed information about the listed projects"""
        if VALID_API_STRING.match(project_names) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        try:
            with api.config["DNFLOCK"].lock:
                projects = projects_info(api.config["DNFLOCK"].dbo, project_names.split(","))
        except ProjectsError as e:
            log.error("(v0_projects_info) %s", str(e))
            return jsonify(status=False, errors=[{"id": PROJECTS_ERROR, "msg": str(e)}]), 400

        if not projects:
            msg = "one of the requested projects does not exist: %s" % project_names
            log.error("(v0_projects_info) %s", msg)
            return jsonify(status=False, errors=[{"id": UNKNOWN_PROJECT, "msg": msg}]), 400

        return jsonify(projects=projects)

    @api.route("/api/v0/projects/depsolve", defaults={'project_names': ""})
    @api.route("/api/v0/projects/depsolve/<project_names>")
    @crossdomain(origin="*")
    @checkparams([("project_names", "", "no project names given")])
    def v0_projects_depsolve(project_names):
        """Return detailed information about the listed projects"""
        if VALID_API_STRING.match(project_names) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        try:
            with api.config["DNFLOCK"].lock:
                deps = projects_depsolve(api.config["DNFLOCK"].dbo, [(n, "*") for n in project_names.split(",")], [])
        except ProjectsError as e:
            log.error("(v0_projects_depsolve) %s", str(e))
            return jsonify(status=False, errors=[{"id": PROJECTS_ERROR, "msg": str(e)}]), 400

        if not deps:
            msg = "one of the requested projects does not exist: %s" % project_names
            log.error("(v0_projects_depsolve) %s", msg)
            return jsonify(status=False, errors=[{"id": UNKNOWN_PROJECT, "msg": msg}]), 400

        return jsonify(projects=deps)

    @api.route("/api/v0/projects/source/list")
    @crossdomain(origin="*")
    def v0_projects_source_list():
        """Return the list of source names"""
        with api.config["DNFLOCK"].lock:
            repos = list(api.config["DNFLOCK"].dbo.repos.iter_enabled())
        sources = sorted([r.id for r in repos])
        return jsonify(sources=sources)

    @api.route("/api/v0/projects/source/info", defaults={'source_names': ""})
    @api.route("/api/v0/projects/source/info/<source_names>")
    @crossdomain(origin="*")
    @checkparams([("source_names", "", "no source names given")])
    def v0_projects_source_info(source_names):
        """Return detailed info about the list of sources"""
        if VALID_API_STRING.match(source_names) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        out_fmt = request.args.get("format", "json")
        if VALID_API_STRING.match(out_fmt) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in format argument"}]), 400

        # Return info on all of the sources
        if source_names == "*":
            with api.config["DNFLOCK"].lock:
                source_names = ",".join(r.id for r in api.config["DNFLOCK"].dbo.repos.iter_enabled())

        sources = {}
        errors = []
        system_sources = get_repo_sources("/etc/yum.repos.d/*.repo")
        for source in source_names.split(","):
            with api.config["DNFLOCK"].lock:
                repo = api.config["DNFLOCK"].dbo.repos.get(source, None)
            if not repo:
                errors.append({"id": UNKNOWN_SOURCE, "msg": "%s is not a valid source" % source})
                continue
            sources[repo.id] = repo_to_source(repo, repo.id in system_sources)

        if out_fmt == "toml" and not errors:
            # With TOML output we just want to dump the raw sources, skipping the errors
            return toml.dumps(sources)
        elif out_fmt == "toml" and errors:
            # TOML requested, but there was an error
            return jsonify(status=False, errors=errors), 400
        else:
            return jsonify(sources=sources, errors=errors)

    @api.route("/api/v0/projects/source/new", methods=["POST"])
    @crossdomain(origin="*")
    def v0_projects_source_new():
        """Add a new package source. Or change an existing one"""
        if request.headers['Content-Type'] == "text/x-toml":
            source = toml.loads(request.data)
        else:
            source = request.get_json(cache=False)

        system_sources = get_repo_sources("/etc/yum.repos.d/*.repo")
        if source["name"] in system_sources:
            return jsonify(status=False, errors=[{"id": SYSTEM_SOURCE, "msg": "%s is a system source, it cannot be changed." % source["name"]}]), 400

        try:
            # Remove it from the RepoDict (NOTE that this isn't explicitly supported by the DNF API)
            with api.config["DNFLOCK"].lock:
                dbo = api.config["DNFLOCK"].dbo
                # If this repo already exists, delete it and replace it with the new one
                repos = list(r.id for r in dbo.repos.iter_enabled())
                if source["name"] in repos:
                    del dbo.repos[source["name"]]

                repo = source_to_repo(source, dbo.conf)
                dbo.repos.add(repo)

                log.info("Updating repository metadata after adding %s", source["name"])
                dbo.fill_sack(load_system_repo=False)
                dbo.read_comps()

            # Write the new repo to disk, replacing any existing ones
            repo_dir = api.config["COMPOSER_CFG"].get("composer", "repo_dir")

            # Remove any previous sources with this name, ignore it if it isn't found
            try:
                delete_repo_source(joinpaths(repo_dir, "*.repo"), source["name"])
            except ProjectsError:
                pass

            # Make sure the source name can't contain a path traversal by taking the basename
            source_path = joinpaths(repo_dir, os.path.basename("%s.repo" % source["name"]))
            with open(source_path, "w") as f:
                f.write(dnf_repo_to_file_repo(repo))
        except Exception as e:
            log.error("(v0_projects_source_add) adding %s failed: %s", source["name"], str(e))

            # Cleanup the mess, if loading it failed we don't want to leave it in memory
            repos = list(r.id for r in dbo.repos.iter_enabled())
            if source["name"] in repos:
                with api.config["DNFLOCK"].lock:
                    dbo = api.config["DNFLOCK"].dbo
                    del dbo.repos[source["name"]]

                    log.info("Updating repository metadata after adding %s failed", source["name"])
                    dbo.fill_sack(load_system_repo=False)
                    dbo.read_comps()

            return jsonify(status=False, errors=[{"id": PROJECTS_ERROR, "msg": str(e)}]), 400

        return jsonify(status=True)

    @api.route("/api/v0/projects/source/delete", defaults={'source_name': ""}, methods=["DELETE"])
    @api.route("/api/v0/projects/source/delete/<source_name>", methods=["DELETE"])
    @crossdomain(origin="*")
    @checkparams([("source_name", "", "no source name given")])
    def v0_projects_source_delete(source_name):
        """Delete the named source and return a status response"""
        if VALID_API_STRING.match(source_name) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        system_sources = get_repo_sources("/etc/yum.repos.d/*.repo")
        if source_name in system_sources:
            return jsonify(status=False, errors=[{"id": SYSTEM_SOURCE, "msg": "%s is a system source, it cannot be deleted." % source_name}]), 400
        share_dir = api.config["COMPOSER_CFG"].get("composer", "repo_dir")
        try:
            # Remove the file entry for the source
            delete_repo_source(joinpaths(share_dir, "*.repo"), source_name)

            # Remove it from the RepoDict (NOTE that this isn't explicitly supported by the DNF API)
            with api.config["DNFLOCK"].lock:
                if source_name in api.config["DNFLOCK"].dbo.repos:
                    del api.config["DNFLOCK"].dbo.repos[source_name]
                    log.info("Updating repository metadata after removing %s", source_name)
                    api.config["DNFLOCK"].dbo.fill_sack(load_system_repo=False)
                    api.config["DNFLOCK"].dbo.read_comps()

        except ProjectsError as e:
            log.error("(v0_projects_source_delete) %s", str(e))
            return jsonify(status=False, errors=[{"id": UNKNOWN_SOURCE, "msg": str(e)}]), 400

        return jsonify(status=True)

    @api.route("/api/v0/modules/list")
    @api.route("/api/v0/modules/list/<module_names>")
    @crossdomain(origin="*")
    def v0_modules_list(module_names=None):
        """List available modules, filtering by module_names"""
        if module_names and VALID_API_STRING.match(module_names) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        try:
            limit = int(request.args.get("limit", "20"))
            offset = int(request.args.get("offset", "0"))
        except ValueError as e:
            return jsonify(status=False, errors=[{"id": BAD_LIMIT_OR_OFFSET, "msg": str(e)}]), 400

        if module_names:
            module_names = module_names.split(",")

        try:
            with api.config["DNFLOCK"].lock:
                available = modules_list(api.config["DNFLOCK"].dbo, module_names)
        except ProjectsError as e:
            log.error("(v0_modules_list) %s", str(e))
            return jsonify(status=False, errors=[{"id": MODULES_ERROR, "msg": str(e)}]), 400

        if module_names and not available:
            msg = "one of the requested modules does not exist: %s" % module_names
            log.error("(v0_modules_list) %s", msg)
            return jsonify(status=False, errors=[{"id": UNKNOWN_MODULE, "msg": msg}]), 400

        modules = take_limits(available, offset, limit)
        return jsonify(modules=modules, offset=offset, limit=limit, total=len(available))

    @api.route("/api/v0/modules/info", defaults={'module_names': ""})
    @api.route("/api/v0/modules/info/<module_names>")
    @crossdomain(origin="*")
    @checkparams([("module_names", "", "no module names given")])
    def v0_modules_info(module_names):
        """Return detailed information about the listed modules"""
        if VALID_API_STRING.match(module_names) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400
        try:
            with api.config["DNFLOCK"].lock:
                modules = modules_info(api.config["DNFLOCK"].dbo, module_names.split(","))
        except ProjectsError as e:
            log.error("(v0_modules_info) %s", str(e))
            return jsonify(status=False, errors=[{"id": MODULES_ERROR, "msg": str(e)}]), 400

        if not modules:
            msg = "one of the requested modules does not exist: %s" % module_names
            log.error("(v0_modules_info) %s", msg)
            return jsonify(status=False, errors=[{"id": UNKNOWN_MODULE, "msg": msg}]), 400

        return jsonify(modules=modules)

    @api.route("/api/v0/compose", methods=["POST"])
    @crossdomain(origin="*")
    def v0_compose_start():
        """Start a compose

        The body of the post should have these fields:
          blueprint_name - The blueprint name from /blueprints/list/
          compose_type   - The type of output to create, from /compose/types
          branch         - Optional, defaults to master, selects the git branch to use for the blueprint.
        """
        # Passing ?test=1 will generate a fake FAILED compose.
        # Passing ?test=2 will generate a fake FINISHED compose.
        try:
            test_mode = int(request.args.get("test", "0"))
        except ValueError:
            test_mode = 0

        compose = request.get_json(cache=False)

        errors = []
        if not compose:
            return jsonify(status=False, errors=[{"id": MISSING_POST, "msg": "Missing POST body"}]), 400

        if "blueprint_name" not in compose:
            errors.append({"id": UNKNOWN_BLUEPRINT,"msg": "No 'blueprint_name' in the JSON request"})
        else:
            blueprint_name = compose["blueprint_name"]

        if "branch" not in compose or not compose["branch"]:
            branch = "master"
        else:
            branch = compose["branch"]

        if "compose_type" not in compose:
            errors.append({"id": BAD_COMPOSE_TYPE, "msg": "No 'compose_type' in the JSON request"})
        else:
            compose_type = compose["compose_type"]

        if VALID_API_STRING.match(blueprint_name) is None:
            errors.append({"id": INVALID_CHARS, "msg": "Invalid characters in API path"})

        if not blueprint_exists(api, branch, blueprint_name):
            errors.append({"id": UNKNOWN_BLUEPRINT, "msg": "Unknown blueprint name: %s" % blueprint_name})

        if errors:
            return jsonify(status=False, errors=errors), 400

        try:
            build_id = start_build(api.config["COMPOSER_CFG"], api.config["DNFLOCK"], api.config["GITLOCK"],
                                   branch, blueprint_name, compose_type, test_mode)
        except Exception as e:
            if "Invalid compose type" in str(e):
                return jsonify(status=False, errors=[{"id": BAD_COMPOSE_TYPE, "msg": str(e)}]), 400
            else:
                return jsonify(status=False, errors=[{"id": BUILD_FAILED, "msg": str(e)}]), 400

        return jsonify(status=True, build_id=build_id)

    @api.route("/api/v0/compose/types")
    @crossdomain(origin="*")
    def v0_compose_types():
        """Return the list of output types and their enabled/disabled state"""
        share_dir = api.config["COMPOSER_CFG"].get("composer", "share_dir")
        return jsonify(types=[{"name": t, "enabled": e} for t, e in compose_types(share_dir)])

    @api.route("/api/v0/compose/queue")
    @crossdomain(origin="*")
    def v0_compose_queue():
        """Return the status of the new and running queues"""
        return jsonify(queue_status(api.config["COMPOSER_CFG"]))

    @api.route("/api/v0/compose/finished")
    @crossdomain(origin="*")
    def v0_compose_finished():
        """Return the list of finished composes"""
        return jsonify(finished=build_status(api.config["COMPOSER_CFG"], "FINISHED"))

    @api.route("/api/v0/compose/failed")
    @crossdomain(origin="*")
    def v0_compose_failed():
        """Return the list of failed composes"""
        return jsonify(failed=build_status(api.config["COMPOSER_CFG"], "FAILED"))

    @api.route("/api/v0/compose/status", defaults={'uuids': ""})
    @api.route("/api/v0/compose/status/<uuids>")
    @crossdomain(origin="*")
    @checkparams([("uuids", "", "no UUIDs given")])
    def v0_compose_status(uuids):
        """Return the status of the listed uuids"""
        if VALID_API_STRING.match(uuids) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        blueprint = request.args.get("blueprint", None)
        status = request.args.get("status", None)
        compose_type = request.args.get("type", None)

        results = []
        errors = []

        if uuids.strip() == '*':
            queue_status_dict = queue_status(api.config["COMPOSER_CFG"])
            queue_new = queue_status_dict["new"]
            queue_running = queue_status_dict["run"]
            candidates = queue_new + queue_running + build_status(api.config["COMPOSER_CFG"])
        else:
            candidates = []
            for uuid in [n.strip().lower() for n in uuids.split(",")]:
                details = uuid_status(api.config["COMPOSER_CFG"], uuid)
                if details is None:
                    errors.append({"id": UNKNOWN_UUID, "msg": "%s is not a valid build uuid" % uuid})
                else:
                    candidates.append(details)

        for details in candidates:
            if blueprint is not None and details['blueprint'] != blueprint:
                continue

            if status is not None and details['queue_status'] != status:
                continue

            if compose_type is not None and details['compose_type'] != compose_type:
                continue

            results.append(details)

        return jsonify(uuids=results, errors=errors)

    @api.route("/api/v0/compose/cancel", defaults={'uuid': ""}, methods=["DELETE"])
    @api.route("/api/v0/compose/cancel/<uuid>", methods=["DELETE"])
    @crossdomain(origin="*")
    @checkparams([("uuid", "", "no UUID given")])
    def v0_compose_cancel(uuid):
        """Cancel a running compose and delete its results directory"""
        if VALID_API_STRING.match(uuid) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        status = uuid_status(api.config["COMPOSER_CFG"], uuid)
        if status is None:
            return jsonify(status=False, errors=[{"id": UNKNOWN_UUID, "msg": "%s is not a valid build uuid" % uuid}]), 400

        if status["queue_status"] not in ["WAITING", "RUNNING"]:
            return jsonify(status=False, errors=[{"id": BUILD_IN_WRONG_STATE, "msg": "Build %s is not in WAITING or RUNNING." % uuid}])

        try:
            uuid_cancel(api.config["COMPOSER_CFG"], uuid)
        except Exception as e:
            return jsonify(status=False, errors=[{"id": COMPOSE_ERROR, "msg": "%s: %s" % (uuid, str(e))}]),400
        else:
            return jsonify(status=True, uuid=uuid)

    @api.route("/api/v0/compose/delete", defaults={'uuids': ""}, methods=["DELETE"])
    @api.route("/api/v0/compose/delete/<uuids>", methods=["DELETE"])
    @crossdomain(origin="*")
    @checkparams([("uuids", "", "no UUIDs given")])
    def v0_compose_delete(uuids):
        """Delete the compose results for the listed uuids"""
        if VALID_API_STRING.match(uuids) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        results = []
        errors = []
        for uuid in [n.strip().lower() for n in uuids.split(",")]:
            status = uuid_status(api.config["COMPOSER_CFG"], uuid)
            if status is None:
                errors.append({"id": UNKNOWN_UUID, "msg": "%s is not a valid build uuid" % uuid})
            elif status["queue_status"] not in ["FINISHED", "FAILED"]:
                errors.append({"id": BUILD_IN_WRONG_STATE, "msg": "Build %s is not in FINISHED or FAILED." % uuid})
            else:
                try:
                    uuid_delete(api.config["COMPOSER_CFG"], uuid)
                except Exception as e:
                    errors.append({"id": COMPOSE_ERROR, "msg": "%s: %s" % (uuid, str(e))})
                else:
                    results.append({"uuid":uuid, "status":True})
        return jsonify(uuids=results, errors=errors)

    @api.route("/api/v0/compose/info", defaults={'uuid': ""})
    @api.route("/api/v0/compose/info/<uuid>")
    @crossdomain(origin="*")
    @checkparams([("uuid", "", "no UUID given")])
    def v0_compose_info(uuid):
        """Return detailed info about a compose"""
        if VALID_API_STRING.match(uuid) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        try:
            info = uuid_info(api.config["COMPOSER_CFG"], uuid)
        except Exception as e:
            return jsonify(status=False, errors=[{"id": COMPOSE_ERROR, "msg": str(e)}]), 400

        if info is None:
            return jsonify(status=False, errors=[{"id": UNKNOWN_UUID, "msg": "%s is not a valid build uuid" % uuid}]), 400
        else:
            return jsonify(**info)

    @api.route("/api/v0/compose/metadata", defaults={'uuid': ""})
    @api.route("/api/v0/compose/metadata/<uuid>")
    @crossdomain(origin="*")
    @checkparams([("uuid","", "no UUID given")])
    def v0_compose_metadata(uuid):
        """Return a tar of the metadata for the build"""
        if VALID_API_STRING.match(uuid) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        status = uuid_status(api.config["COMPOSER_CFG"], uuid)
        if status is None:
            return jsonify(status=False, errors=[{"id": UNKNOWN_UUID, "msg": "%s is not a valid build uuid" % uuid}]), 400
        if status["queue_status"] not in ["FINISHED", "FAILED"]:
            return jsonify(status=False, errors=[{"id": BUILD_IN_WRONG_STATE, "msg": "Build %s not in FINISHED or FAILED state." % uuid}]), 400
        else:
            return Response(uuid_tar(api.config["COMPOSER_CFG"], uuid, metadata=True, image=False, logs=False),
                            mimetype="application/x-tar",
                            headers=[("Content-Disposition", "attachment; filename=%s-metadata.tar;" % uuid)],
                            direct_passthrough=True)

    @api.route("/api/v0/compose/results", defaults={'uuid': ""})
    @api.route("/api/v0/compose/results/<uuid>")
    @crossdomain(origin="*")
    @checkparams([("uuid","", "no UUID given")])
    def v0_compose_results(uuid):
        """Return a tar of the metadata and the results for the build"""
        if VALID_API_STRING.match(uuid) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        status = uuid_status(api.config["COMPOSER_CFG"], uuid)
        if status is None:
            return jsonify(status=False, errors=[{"id": UNKNOWN_UUID, "msg": "%s is not a valid build uuid" % uuid}]), 400
        elif status["queue_status"] not in ["FINISHED", "FAILED"]:
            return jsonify(status=False, errors=[{"id": BUILD_IN_WRONG_STATE, "msg": "Build %s not in FINISHED or FAILED state." % uuid}]), 400
        else:
            return Response(uuid_tar(api.config["COMPOSER_CFG"], uuid, metadata=True, image=True, logs=True),
                            mimetype="application/x-tar",
                            headers=[("Content-Disposition", "attachment; filename=%s.tar;" % uuid)],
                            direct_passthrough=True)

    @api.route("/api/v0/compose/logs", defaults={'uuid': ""})
    @api.route("/api/v0/compose/logs/<uuid>")
    @crossdomain(origin="*")
    @checkparams([("uuid","", "no UUID given")])
    def v0_compose_logs(uuid):
        """Return a tar of the metadata for the build"""
        if VALID_API_STRING.match(uuid) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        status = uuid_status(api.config["COMPOSER_CFG"], uuid)
        if status is None:
            return jsonify(status=False, errors=[{"id": UNKNOWN_UUID, "msg": "%s is not a valid build uuid" % uuid}]), 400
        elif status["queue_status"] not in ["FINISHED", "FAILED"]:
            return jsonify(status=False, errors=[{"id": BUILD_IN_WRONG_STATE, "msg": "Build %s not in FINISHED or FAILED state." % uuid}]), 400
        else:
            return Response(uuid_tar(api.config["COMPOSER_CFG"], uuid, metadata=False, image=False, logs=True),
                            mimetype="application/x-tar",
                            headers=[("Content-Disposition", "attachment; filename=%s-logs.tar;" % uuid)],
                            direct_passthrough=True)

    @api.route("/api/v0/compose/image", defaults={'uuid': ""})
    @api.route("/api/v0/compose/image/<uuid>")
    @crossdomain(origin="*")
    @checkparams([("uuid","", "no UUID given")])
    def v0_compose_image(uuid):
        """Return the output image for the build"""
        if VALID_API_STRING.match(uuid) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        status = uuid_status(api.config["COMPOSER_CFG"], uuid)
        if status is None:
            return jsonify(status=False, errors=[{"id": UNKNOWN_UUID, "msg": "%s is not a valid build uuid" % uuid}]), 400
        elif status["queue_status"] not in ["FINISHED", "FAILED"]:
            return jsonify(status=False, errors=[{"id": BUILD_IN_WRONG_STATE, "msg": "Build %s not in FINISHED or FAILED state." % uuid}]), 400
        else:
            image_name, image_path = uuid_image(api.config["COMPOSER_CFG"], uuid)

            # Make sure it really exists
            if not os.path.exists(image_path):
                return jsonify(status=False, errors=[{"id": BUILD_MISSING_FILE, "msg": "Build %s is missing image file %s" % (uuid, image_name)}]), 400

            # Make the image name unique
            image_name = uuid + "-" + image_name
            # XXX - Will mime type guessing work for all our output?
            return send_file(image_path, as_attachment=True, attachment_filename=image_name, add_etags=False)

    @api.route("/api/v0/compose/log", defaults={'uuid': ""})
    @api.route("/api/v0/compose/log/<uuid>")
    @crossdomain(origin="*")
    @checkparams([("uuid","", "no UUID given")])
    def v0_compose_log_tail(uuid):
        """Return the end of the main anaconda.log, defaults to 1Mbytes"""
        if VALID_API_STRING.match(uuid) is None:
            return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400

        try:
            size = int(request.args.get("size", "1024"))
        except ValueError as e:
            return jsonify(status=False, errors=[{"id": COMPOSE_ERROR, "msg": str(e)}]), 400

        status = uuid_status(api.config["COMPOSER_CFG"], uuid)
        if status is None:
            return jsonify(status=False, errors=[{"id": UNKNOWN_UUID, "msg": "%s is not a valid build uuid" % uuid}]), 400
        elif status["queue_status"] == "WAITING":
            return jsonify(status=False, errors=[{"id": BUILD_IN_WRONG_STATE, "msg": "Build %s has not started yet. No logs to view" % uuid}])
        try:
            return Response(uuid_log(api.config["COMPOSER_CFG"], uuid, size), direct_passthrough=True)
        except RuntimeError as e:
            return jsonify(status=False, errors=[{"id": COMPOSE_ERROR, "msg": str(e)}]), 400
