diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4362b49 --- /dev/null +++ b/LICENSE @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library 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 + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b951d0f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include LICENSE +include README.md +include pyproject.toml +include requirements.txt +include doc/nmstatectl.8.in +recursive-include examples *.yml +exclude packaging/nmstate.spec diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..6bea899 --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,175 @@ +Metadata-Version: 2.1 +Name: nmstate +Version: 0.3.4 +Summary: Declarative network manager API +Home-page: https://nmstate.github.io/ +Author: Edward Haas +Author-email: ehaas@redhat.com +License: LGPL2.1+ +Description: # We are Nmstate! + A declarative network manager API for hosts. + + [![Test Status](https://travis-ci.com/nmstate/nmstate.png?branch=master)](https://travis-ci.com/nmstate/nmstate) + [![Coverage Status](https://coveralls.io/repos/github/nmstate/nmstate/badge.svg?branch=master)](https://coveralls.io/github/nmstate/nmstate?branch=master) + [![PyPI version](https://badge.fury.io/py/nmstate.svg)](https://badge.fury.io/py/nmstate) + [![Fedora Rawhide version](https://img.shields.io/badge/dynamic/json.svg?label=Fedora%20Rawhide&url=https%3A%2F%2Fapps.fedoraproject.org%2Fmdapi%2Frawhide%2Fpkg%2Fnmstate&query=%24.version&colorB=blue)](https://apps.fedoraproject.org/packages/nmstate) + [![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) + [![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/nmstate/nmstate.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/nmstate/nmstate/context:python) + + Copr build status, all repos are built for Fedora 31+ and RHEL/CentOS/EPEL 8: + + * Latest release: [![Latest release Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate/package/nmstate/) + * Git master: [![Git master Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-git/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-git/package/nmstate/) + * Latest 0.2 release: [![Latest 0.2 release Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2/package/nmstate/) + * Git nmstate-0.2: [![Git nmstate-0.2 Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2-git/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2-git/package/nmstate/) + + ## What is it? + Nmstate is a library with an accompanying command line tool that manages + host networking settings in a declarative manner. + The networking state is described by a pre-defined schema. + Reporting of current state and changes to it (desired state) both conform to + the schema. + + Nmstate is aimed to satisfy enterprise needs to manage host networking through + a northbound declarative API and multi provider support on the southbound. + NetworkManager acts as the main (and currently the only) provider supported. + + ## State example: + + Desired/Current state example (YAML): + ```yaml + interfaces: + - name: eth1 + type: ethernet + state: up + ipv4: + enabled: true + address: + - ip: 192.0.2.10 + prefix-length: 24 + dhcp: false + ipv6: + enabled: true + address: + - ip: 2001:db8:1::a + prefix-length: 64 + autoconf: false + dhcp: false + dns-resolver: + config: + search: + - example.com + - example.org + server: + - 2001:4860:4860::8888 + - 8.8.8.8 + routes: + config: + - destination: 0.0.0.0/0 + next-hop-address: 192.0.2.1 + next-hop-interface: eth1 + - destination: ::/0 + next-hop-address: 2001:db8:1::1 + next-hop-interface: eth1 + ``` + + ## Basic Operations + + Show eth0 current state (python/shell): + + ```python + import libnmstate + + state = libnmstate.show() + eth0_state = next(ifstate for ifstate in state['interfaces'] if ifstate['name'] == 'eth0') + + # Here is the MAC address + eth0_mac = eth0_state['mac-address'] + ``` + + ```shell + nmstatectl show eth0 + ``` + + Change to desired state (python/shell): + + ```python + import libnmstate + + # Specify a Linux bridge (created if it does not exist). + state = {'interfaces': [{'name': 'br0', 'type': 'linux-bridge', 'state': 'up'}]} + libnmstate.apply(state) + ``` + + ```shell + # use yaml or json formats + nmstatectl set desired-state.yml + nmstatectl set desired-state.json + ``` + + Edit the current state(python/shell): + ```python + import libnmstate + + state = libnmstate.show() + eth0_state = next(ifstate for ifstate in state['interfaces'] if ifstate['name'] == 'eth0') + + # take eth0 down + eth0_state['state'] = 'down' + libnmstate.apply(state) + ``` + + ```shell + # open current state in a text editor, change and save to apply + nmstatectl edit eth3 + ``` + + ## Contact + + *Nmstate* uses the [nmstate-devel@lists.fedorahosted.org][mailing_list] for + discussions. To subscribe you can send an email with 'subscribe' in the subject + to or visit the + [mailing list page][mailing_list]. + + Development planning (sprints and progress reporting) happens in + ([Jira](https://nmstate.atlassian.net)). Access requires login. + + There is also `#nmstate` on + [Freenode IRC](https://freenode.net/kb/answer/chat). + + ## Contributing + + Yay! We are happy to accept new contributors to the Nmstate project. Please follow + these [instructions](CONTRIBUTING.md) to contribute. + + ## Installation + + For Fedora 29+, `sudo dnf install nmstate`. + + For others distribution, please see the [install](README.install.md) + instructions. + + ## Documentation + + * [libnmstate API](https://nmstate.github.io/devel/api.html) + * [Code examples](https://nmstate.github.io/devel/py_example.html) + * [State examples](https://nmstate.github.io/examples.html) + * [nmstatectl user guide](https://nmstate.github.io/cli_guide.html) + * nmstatectl man page: `man nmstatectl` + + ## Limitations + + Please refer to [jira page][jira_limitation] for details. + + * Maximum supported number of interfaces in a single desire state is 1000. + + ## Changelog + + Please refer to [CHANGELOG](CHANGELOG) + + + [jira_limitation]: https://nmstate.atlassian.net/issues/?filter=10003 + [mailing_list]: https://lists.fedorahosted.org/admin/lists/nmstate-devel.lists.fedorahosted.org + +Platform: UNKNOWN +Description-Content-Type: text/markdown diff --git a/README.md b/README.md new file mode 100644 index 0000000..65e29f9 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# We are Nmstate! +A declarative network manager API for hosts. + +[![Test Status](https://travis-ci.com/nmstate/nmstate.png?branch=master)](https://travis-ci.com/nmstate/nmstate) +[![Coverage Status](https://coveralls.io/repos/github/nmstate/nmstate/badge.svg?branch=master)](https://coveralls.io/github/nmstate/nmstate?branch=master) +[![PyPI version](https://badge.fury.io/py/nmstate.svg)](https://badge.fury.io/py/nmstate) +[![Fedora Rawhide version](https://img.shields.io/badge/dynamic/json.svg?label=Fedora%20Rawhide&url=https%3A%2F%2Fapps.fedoraproject.org%2Fmdapi%2Frawhide%2Fpkg%2Fnmstate&query=%24.version&colorB=blue)](https://apps.fedoraproject.org/packages/nmstate) +[![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) +[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/nmstate/nmstate.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/nmstate/nmstate/context:python) + +Copr build status, all repos are built for Fedora 31+ and RHEL/CentOS/EPEL 8: + +* Latest release: [![Latest release Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate/package/nmstate/) +* Git master: [![Git master Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-git/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-git/package/nmstate/) +* Latest 0.2 release: [![Latest 0.2 release Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2/package/nmstate/) +* Git nmstate-0.2: [![Git nmstate-0.2 Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2-git/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2-git/package/nmstate/) + +## What is it? +Nmstate is a library with an accompanying command line tool that manages +host networking settings in a declarative manner. +The networking state is described by a pre-defined schema. +Reporting of current state and changes to it (desired state) both conform to +the schema. + +Nmstate is aimed to satisfy enterprise needs to manage host networking through +a northbound declarative API and multi provider support on the southbound. +NetworkManager acts as the main (and currently the only) provider supported. + +## State example: + +Desired/Current state example (YAML): +```yaml +interfaces: +- name: eth1 + type: ethernet + state: up + ipv4: + enabled: true + address: + - ip: 192.0.2.10 + prefix-length: 24 + dhcp: false + ipv6: + enabled: true + address: + - ip: 2001:db8:1::a + prefix-length: 64 + autoconf: false + dhcp: false +dns-resolver: + config: + search: + - example.com + - example.org + server: + - 2001:4860:4860::8888 + - 8.8.8.8 +routes: + config: + - destination: 0.0.0.0/0 + next-hop-address: 192.0.2.1 + next-hop-interface: eth1 + - destination: ::/0 + next-hop-address: 2001:db8:1::1 + next-hop-interface: eth1 +``` + +## Basic Operations + +Show eth0 current state (python/shell): + +```python +import libnmstate + +state = libnmstate.show() +eth0_state = next(ifstate for ifstate in state['interfaces'] if ifstate['name'] == 'eth0') + +# Here is the MAC address +eth0_mac = eth0_state['mac-address'] +``` + +```shell +nmstatectl show eth0 +``` + +Change to desired state (python/shell): + +```python +import libnmstate + +# Specify a Linux bridge (created if it does not exist). +state = {'interfaces': [{'name': 'br0', 'type': 'linux-bridge', 'state': 'up'}]} +libnmstate.apply(state) +``` + +```shell +# use yaml or json formats +nmstatectl set desired-state.yml +nmstatectl set desired-state.json +``` + +Edit the current state(python/shell): +```python +import libnmstate + +state = libnmstate.show() +eth0_state = next(ifstate for ifstate in state['interfaces'] if ifstate['name'] == 'eth0') + +# take eth0 down +eth0_state['state'] = 'down' +libnmstate.apply(state) +``` + +```shell +# open current state in a text editor, change and save to apply +nmstatectl edit eth3 +``` + +## Contact + +*Nmstate* uses the [nmstate-devel@lists.fedorahosted.org][mailing_list] for +discussions. To subscribe you can send an email with 'subscribe' in the subject +to or visit the +[mailing list page][mailing_list]. + +Development planning (sprints and progress reporting) happens in +([Jira](https://nmstate.atlassian.net)). Access requires login. + +There is also `#nmstate` on +[Freenode IRC](https://freenode.net/kb/answer/chat). + +## Contributing + +Yay! We are happy to accept new contributors to the Nmstate project. Please follow +these [instructions](CONTRIBUTING.md) to contribute. + +## Installation + +For Fedora 29+, `sudo dnf install nmstate`. + +For others distribution, please see the [install](README.install.md) +instructions. + +## Documentation + +* [libnmstate API](https://nmstate.github.io/devel/api.html) +* [Code examples](https://nmstate.github.io/devel/py_example.html) +* [State examples](https://nmstate.github.io/examples.html) +* [nmstatectl user guide](https://nmstate.github.io/cli_guide.html) +* nmstatectl man page: `man nmstatectl` + +## Limitations + +Please refer to [jira page][jira_limitation] for details. + +* Maximum supported number of interfaces in a single desire state is 1000. + +## Changelog + +Please refer to [CHANGELOG](CHANGELOG) + + +[jira_limitation]: https://nmstate.atlassian.net/issues/?filter=10003 +[mailing_list]: https://lists.fedorahosted.org/admin/lists/nmstate-devel.lists.fedorahosted.org diff --git a/doc/nmstatectl.8 b/doc/nmstatectl.8 new file mode 100644 index 0000000..b243bfa --- /dev/null +++ b/doc/nmstatectl.8 @@ -0,0 +1,111 @@ +.\" Manpage for nmstatectl. +.TH nmstatectl 8 "July 24, 2020" "0.3.4" "nmstatectl man page" +.SH NAME +nmstatectl \- A nmstate command line tool +.SH SYNOPSIS +.B nmstatectl show \fR[\fIINTERFACE_NAME\fR] [\fB--json\fR] +.br +.B nmstatectl set \fISTATE_FILE_PATH\fR [\fIOPTIONS\fR] +.br +.B nmstatectl edit \fR[\fIINTERFACE_NAME\fR] [\fIOPTIONS\fR] +.br +.B nmstatectl rollback \fR[\fICHECKPOINT_PATH\fR] +.br +.B nmstatectl commit \fR[\fICHECKPOINT_PATH\fR] +.br +.B nmstatectl version +.SH DESCRIPTION +.B nmstatectl\fR is created for users who want to try out nmstate without using +\fIlibnmstate\fR. +.PP +.B show +.RS +Query the current network state. \fIYAML\fR is the default output format. Use +the \fB--json\fR argument to change the output format to \fIJSON\fR. To limit +the output state to include certain interfaces only, please specify the +interface name. Please be advised, global config like DNS will be included. +.PP +For multiple interface names, use comma to separate them. You can also use +patterns for interface names: +.RS +.B *\fR matches everything +.br +.B ?\fR matches any single character +.br +.B [seq]\fR matches any character in seq +.br +.B [!seq]\fR matches any character not in seq +.RE +.PP +For example, to show all interfaces starts with eth: +.RS +nmstatectl show eth\\* +.br +# The backslash is required to stop shell expanding '*' to file names. +.RE +.RE +.PP +.B set +.RS +Apply the network state from specified file in \fIYAML\fR or \fIJSON\fR format. +By default, if the network state after state applied is not identical to the +desired state, \fBnmstatectl\fR rollbacks to the state before \fBset\fR +command. Use the \fB--no-verify\fR argument to skip the verification. +.RE +.PP +.B edit +.RS +.B nmstatectl\fR will invoke the text editor defined by environment variable +\fIEDITOR\fR for editing the network state in \fIYAML\fR format. Once the text +editor quit, \fBnmstatectl\fR will try to apply it using \fB"nmstatectl set"\fR. +.br +If there is any syntax error, you will be asked to edit again. Multiple +interfaces are supported, check \fIshow\fR for detail. +.PP +By default, if the network state after state applied is not identical to the +desired state, \fBnmstatectl\fR rollbacks to the state before \fBedit\fR +command. Use the \fB--no-verify\fR argument to skip the verification. +.RE +.PP +.B nmstatectl\fR supports manual transaction control which allows user to +decide whether rollback to previous (before \fB"nmstatectl set/edit"\fR) state. +.IP \fBrollback +rollback the network state from specified checkpoint file. \fBnmstatectl\fR +will take the latest checkpoint if not defined as argument. +.PP +.B commit +.RS +commit the current network state. \fBnmstatectl\fR will take the latest +checkpoint if not defined as argument. +.RE +.B version +.RS +displays nmstate version. +.SH OPTIONS +.B --json +.RS +change the output format to \fIJSON\fR. +.RE +.IP \fB--no-verify +skip the desired network state verification. +.IP \fB--no-commit +create a checkpoint which later could be used for rollback or commit. The +checkpoint will be the last line of \fBnmstatectl\fR output, example: +\fI/org/freedesktop/NetworkManager/Checkpoint/1\fR. +.IP \fB--memory-only +all the changes done will be non persistent, they are going to be removed after +rebooting. +.IP \fB--timeout\fR=<\fITIMEOUT\fR> +the user must commit the changes within \fItimeout\fR, or they will be +automatically rolled back. Default: 60 seconds. +.IP \fB--version +displays nmstate version. +.SH LIMITATIONS +*\fR Maximum supported number of interfaces in a single desire state is 1000. +.SH BUG REPORTS +Report bugs on nmstate GitHub issues . +.SH COPYRIGHT +License LGPL-2.1 or any later version +. +.SH SEE ALSO +.B NetworkManager\fP(8) diff --git a/doc/nmstatectl.8.in b/doc/nmstatectl.8.in new file mode 100644 index 0000000..75e00b2 --- /dev/null +++ b/doc/nmstatectl.8.in @@ -0,0 +1,111 @@ +.\" Manpage for nmstatectl. +.TH nmstatectl 8 "@DATE@" "@VERSION@" "nmstatectl man page" +.SH NAME +nmstatectl \- A nmstate command line tool +.SH SYNOPSIS +.B nmstatectl show \fR[\fIINTERFACE_NAME\fR] [\fB--json\fR] +.br +.B nmstatectl set \fISTATE_FILE_PATH\fR [\fIOPTIONS\fR] +.br +.B nmstatectl edit \fR[\fIINTERFACE_NAME\fR] [\fIOPTIONS\fR] +.br +.B nmstatectl rollback \fR[\fICHECKPOINT_PATH\fR] +.br +.B nmstatectl commit \fR[\fICHECKPOINT_PATH\fR] +.br +.B nmstatectl version +.SH DESCRIPTION +.B nmstatectl\fR is created for users who want to try out nmstate without using +\fIlibnmstate\fR. +.PP +.B show +.RS +Query the current network state. \fIYAML\fR is the default output format. Use +the \fB--json\fR argument to change the output format to \fIJSON\fR. To limit +the output state to include certain interfaces only, please specify the +interface name. Please be advised, global config like DNS will be included. +.PP +For multiple interface names, use comma to separate them. You can also use +patterns for interface names: +.RS +.B *\fR matches everything +.br +.B ?\fR matches any single character +.br +.B [seq]\fR matches any character in seq +.br +.B [!seq]\fR matches any character not in seq +.RE +.PP +For example, to show all interfaces starts with eth: +.RS +nmstatectl show eth\\* +.br +# The backslash is required to stop shell expanding '*' to file names. +.RE +.RE +.PP +.B set +.RS +Apply the network state from specified file in \fIYAML\fR or \fIJSON\fR format. +By default, if the network state after state applied is not identical to the +desired state, \fBnmstatectl\fR rollbacks to the state before \fBset\fR +command. Use the \fB--no-verify\fR argument to skip the verification. +.RE +.PP +.B edit +.RS +.B nmstatectl\fR will invoke the text editor defined by environment variable +\fIEDITOR\fR for editing the network state in \fIYAML\fR format. Once the text +editor quit, \fBnmstatectl\fR will try to apply it using \fB"nmstatectl set"\fR. +.br +If there is any syntax error, you will be asked to edit again. Multiple +interfaces are supported, check \fIshow\fR for detail. +.PP +By default, if the network state after state applied is not identical to the +desired state, \fBnmstatectl\fR rollbacks to the state before \fBedit\fR +command. Use the \fB--no-verify\fR argument to skip the verification. +.RE +.PP +.B nmstatectl\fR supports manual transaction control which allows user to +decide whether rollback to previous (before \fB"nmstatectl set/edit"\fR) state. +.IP \fBrollback +rollback the network state from specified checkpoint file. \fBnmstatectl\fR +will take the latest checkpoint if not defined as argument. +.PP +.B commit +.RS +commit the current network state. \fBnmstatectl\fR will take the latest +checkpoint if not defined as argument. +.RE +.B version +.RS +displays nmstate version. +.SH OPTIONS +.B --json +.RS +change the output format to \fIJSON\fR. +.RE +.IP \fB--no-verify +skip the desired network state verification. +.IP \fB--no-commit +create a checkpoint which later could be used for rollback or commit. The +checkpoint will be the last line of \fBnmstatectl\fR output, example: +\fI/org/freedesktop/NetworkManager/Checkpoint/1\fR. +.IP \fB--memory-only +all the changes done will be non persistent, they are going to be removed after +rebooting. +.IP \fB--timeout\fR=<\fITIMEOUT\fR> +the user must commit the changes within \fItimeout\fR, or they will be +automatically rolled back. Default: 60 seconds. +.IP \fB--version +displays nmstate version. +.SH LIMITATIONS +*\fR Maximum supported number of interfaces in a single desire state is 1000. +.SH BUG REPORTS +Report bugs on nmstate GitHub issues . +.SH COPYRIGHT +License LGPL-2.1 or any later version +. +.SH SEE ALSO +.B NetworkManager\fP(8) diff --git a/examples/bond_linuxbridge_vlan_absent.yml b/examples/bond_linuxbridge_vlan_absent.yml new file mode 100644 index 0000000..70a0aca --- /dev/null +++ b/examples/bond_linuxbridge_vlan_absent.yml @@ -0,0 +1,19 @@ +--- +interfaces: + - name: eth1 + state: down + - name: eth2 + type: ethernet + state: down + - name: bond0 + type: bond + state: absent + - name: br0 + type: linux-bridge + state: absent + - name: vlan29 + type: vlan + state: absent + - name: br29 + type: linux-bridge + state: absent diff --git a/examples/bond_linuxbridge_vlan_up.yml b/examples/bond_linuxbridge_vlan_up.yml new file mode 100644 index 0000000..e5daabf --- /dev/null +++ b/examples/bond_linuxbridge_vlan_up.yml @@ -0,0 +1,36 @@ +--- +# eth1 --+-- bond0 --+-- br0 +# eth2 --' '-- vlan29 -- br29 +interfaces: + - name: vlan29 + type: vlan + state: up + vlan: + base-iface: bond0 + id: 29 + - name: br29 + type: linux-bridge + state: up + bridge: + port: + - name: vlan29 + - name: br0 + type: linux-bridge + state: up + bridge: + port: + - name: bond0 + - name: bond0 + type: bond + state: up + link-aggregation: + mode: active-backup + slaves: + - eth1 + - eth2 + - name: eth1 + type: ethernet + state: up + - name: eth2 + type: ethernet + state: up diff --git a/examples/dns_edit_eth1.yml b/examples/dns_edit_eth1.yml new file mode 100644 index 0000000..accbc8b --- /dev/null +++ b/examples/dns_edit_eth1.yml @@ -0,0 +1,34 @@ +--- +dns-resolver: + config: + search: + - example.com + - example.org + server: + - 2001:4860:4860::8888 + - 8.8.8.8 +routes: + config: + - destination: 0.0.0.0/0 + next-hop-address: 192.0.2.1 + next-hop-interface: eth1 + - destination: ::/0 + next-hop-address: 2001:db8:1::1 + next-hop-interface: eth1 +interfaces: +- name: eth1 + type: ethernet + state: up + ipv4: + address: + - ip: 192.0.2.10 + prefix-length: 24 + dhcp: false + enabled: true + ipv6: + address: + - ip: 2001:db8:1::a + prefix-length: 64 + autoconf: false + dhcp: false + enabled: true diff --git a/examples/dns_remove.yml b/examples/dns_remove.yml new file mode 100644 index 0000000..35854a8 --- /dev/null +++ b/examples/dns_remove.yml @@ -0,0 +1,4 @@ +--- +dns-resolver: + config: {} +interfaces: [] diff --git a/examples/eth1_add_route.yml b/examples/eth1_add_route.yml new file mode 100644 index 0000000..daf78cb --- /dev/null +++ b/examples/eth1_add_route.yml @@ -0,0 +1,19 @@ +--- +interfaces: + - name: eth1 + type: ethernet + state: up + ipv4: + address: + - ip: 192.0.2.251 + prefix-length: 24 + dhcp: false + enabled: true + +routes: + config: + - destination: 198.51.100.0/24 + metric: 150 + next-hop-address: 192.0.2.1 + next-hop-interface: eth1 + table-id: 254 diff --git a/examples/eth1_del_all_routes.yml b/examples/eth1_del_all_routes.yml new file mode 100644 index 0000000..f010fd1 --- /dev/null +++ b/examples/eth1_del_all_routes.yml @@ -0,0 +1,8 @@ +--- +interfaces: + - name: eth1 + +routes: + config: + - next-hop-interface: eth1 + state: absent diff --git a/examples/eth1_with_sriov.yml b/examples/eth1_with_sriov.yml new file mode 100644 index 0000000..fd29d99 --- /dev/null +++ b/examples/eth1_with_sriov.yml @@ -0,0 +1,13 @@ +--- +interfaces: +- name: eth1 + type: ethernet + state: up + ethernet: + sr-iov: + total-vfs: 1 + vfs: + - id: 0 + mac-address: ee:2a:4e:8e:71:f5 + spoof-check: true + trust: false diff --git a/examples/linuxbrige_eth1_absent.yml b/examples/linuxbrige_eth1_absent.yml new file mode 100644 index 0000000..17af152 --- /dev/null +++ b/examples/linuxbrige_eth1_absent.yml @@ -0,0 +1,5 @@ +--- +interfaces: + - name: linux-br0 + type: linux-bridge + state: absent diff --git a/examples/linuxbrige_eth1_up.yml b/examples/linuxbrige_eth1_up.yml new file mode 100644 index 0000000..dc1371c --- /dev/null +++ b/examples/linuxbrige_eth1_up.yml @@ -0,0 +1,24 @@ +--- +interfaces: + - name: eth1 + type: ethernet + state: up + - name: linux-br0 + type: linux-bridge + state: up + bridge: + options: + group-forward-mask: 0 + mac-ageing-time: 300 + multicast-snooping: true + stp: + enabled: true + forward-delay: 15 + hello-time: 2 + max-age: 20 + priority: 32768 + port: + - name: eth1 + stp-hairpin-mode: false + stp-path-cost: 100 + stp-priority: 32 diff --git a/examples/linuxbrige_eth1_up_port_vlan.yml b/examples/linuxbrige_eth1_up_port_vlan.yml new file mode 100644 index 0000000..78485d6 --- /dev/null +++ b/examples/linuxbrige_eth1_up_port_vlan.yml @@ -0,0 +1,23 @@ +--- +interfaces: + - name: eth1 + type: ethernet + state: up + - name: linux-br0 + type: linux-bridge + state: up + bridge: + port: + - name: eth1 + stp-hairpin-mode: false + stp-path-cost: 100 + stp-priority: 32 + vlan: + mode: trunk + trunk-tags: + - id: 101 + - id-range: + min: 500 + max: 599 + tag: 100 + enable-native: true diff --git a/examples/ovsbridge_bond_create.yml b/examples/ovsbridge_bond_create.yml new file mode 100644 index 0000000..631de89 --- /dev/null +++ b/examples/ovsbridge_bond_create.yml @@ -0,0 +1,15 @@ +--- +interfaces: + - name: ovs-br0 + type: ovs-bridge + state: up + bridge: + options: + stp: false + port: + - name: ovs-bond1 + link-aggregation: + mode: balance-slb + slaves: + - name: eth1 + - name: eth2 diff --git a/examples/ovsbridge_create.yml b/examples/ovsbridge_create.yml new file mode 100644 index 0000000..b69804c --- /dev/null +++ b/examples/ovsbridge_create.yml @@ -0,0 +1,25 @@ +--- +interfaces: + - name: eth1 + type: ethernet + state: up + - name: ovs0 + type: ovs-interface + state: up + ipv4: + enabled: true + address: + - ip: 192.0.2.1 + prefix-length: 24 + - name: ovs-br0 + type: ovs-bridge + state: up + bridge: + options: + fail-mode: '' + mcast-snooping-enable: false + rstp: false + stp: true + port: + - name: eth1 + - name: ovs0 diff --git a/examples/ovsbridge_delete.yml b/examples/ovsbridge_delete.yml new file mode 100644 index 0000000..511ea93 --- /dev/null +++ b/examples/ovsbridge_delete.yml @@ -0,0 +1,5 @@ +--- +interfaces: + - name: ovs-br0 + type: ovs-bridge + state: absent diff --git a/examples/ovsbridge_patch_create.yml b/examples/ovsbridge_patch_create.yml new file mode 100644 index 0000000..45dbd74 --- /dev/null +++ b/examples/ovsbridge_patch_create.yml @@ -0,0 +1,28 @@ +--- +interfaces: +- name: patch0 + type: ovs-interface + state: up + patch: + peer: patch1 +- name: ovs-br0 + type: ovs-bridge + state: up + bridge: + options: + stp: true + port: + - name: patch0 +- name: patch1 + type: ovs-interface + state: up + patch: + peer: patch0 +- name: ovs-br1 + type: ovs-bridge + state: up + bridge: + options: + stp: true + port: + - name: patch1 diff --git a/examples/ovsbridge_patch_delete.yml b/examples/ovsbridge_patch_delete.yml new file mode 100644 index 0000000..96fd85c --- /dev/null +++ b/examples/ovsbridge_patch_delete.yml @@ -0,0 +1,6 @@ +--- +interfaces: +- name: ovs-br0 + state: absent +- name: ovs-br1 + state: absent diff --git a/examples/ovsbridge_vlan_port.yml b/examples/ovsbridge_vlan_port.yml new file mode 100644 index 0000000..b0e6100 --- /dev/null +++ b/examples/ovsbridge_vlan_port.yml @@ -0,0 +1,14 @@ +--- +interfaces: + - name: ovs0 + type: ovs-interface + state: up + - name: ovs-br0 + type: ovs-bridge + state: up + bridge: + port: + - name: ovs0 + vlan: + mode: access + tag: 2 diff --git a/examples/team0_absent.yml b/examples/team0_absent.yml new file mode 100644 index 0000000..5058047 --- /dev/null +++ b/examples/team0_absent.yml @@ -0,0 +1,5 @@ +--- +interfaces: +- name: team0 + type: team + state: absent diff --git a/examples/team0_with_slaves.yml b/examples/team0_with_slaves.yml new file mode 100644 index 0000000..8b019fd --- /dev/null +++ b/examples/team0_with_slaves.yml @@ -0,0 +1,11 @@ +--- +interfaces: +- name: team0 + type: team + state: up + team: + ports: + - name: eth1 + - name: eth2 + runner: + name: loadbalance diff --git a/examples/vlan101_eth1_absent.yml b/examples/vlan101_eth1_absent.yml new file mode 100644 index 0000000..52d597f --- /dev/null +++ b/examples/vlan101_eth1_absent.yml @@ -0,0 +1,8 @@ +--- +interfaces: + - name: eth1.101 + type: vlan + state: absent + vlan: + base-iface: eth1 + id: 101 diff --git a/examples/vlan101_eth1_down.yml b/examples/vlan101_eth1_down.yml new file mode 100644 index 0000000..cf5bbac --- /dev/null +++ b/examples/vlan101_eth1_down.yml @@ -0,0 +1,8 @@ +--- +interfaces: + - name: eth1.101 + type: vlan + state: down + vlan: + base-iface: eth1 + id: 101 diff --git a/examples/vlan101_eth1_up.yml b/examples/vlan101_eth1_up.yml new file mode 100644 index 0000000..4580119 --- /dev/null +++ b/examples/vlan101_eth1_up.yml @@ -0,0 +1,8 @@ +--- +interfaces: + - name: eth1.101 + type: vlan + state: up + vlan: + base-iface: eth1 + id: 101 diff --git a/libnmstate/VERSION b/libnmstate/VERSION new file mode 100644 index 0000000..42045ac --- /dev/null +++ b/libnmstate/VERSION @@ -0,0 +1 @@ +0.3.4 diff --git a/libnmstate/__init__.py b/libnmstate/__init__.py new file mode 100644 index 0000000..048ba11 --- /dev/null +++ b/libnmstate/__init__.py @@ -0,0 +1,52 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import os + +from . import error +from . import schema + +from .netapplier import apply +from .netapplier import commit +from .netapplier import rollback +from .netinfo import show + +from .prettystate import PrettyState + + +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) + +__all__ = [ + "show", + "apply", + "commit", + "rollback", + "error", + "schema", + "PrettyState", +] + + +def _get_version(): + with open(os.path.join(ROOT_DIR, "VERSION")) as f: + version = f.read().strip() + return version + + +__version__ = _get_version() diff --git a/libnmstate/dns.py b/libnmstate/dns.py new file mode 100644 index 0000000..e41220f --- /dev/null +++ b/libnmstate/dns.py @@ -0,0 +1,231 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from copy import deepcopy + +from libnmstate.error import NmstateValueError +from libnmstate.error import NmstateVerificationError +from libnmstate.error import NmstateNotImplementedError +from libnmstate.iplib import is_ipv6_address +from libnmstate.prettystate import format_desired_current_state_diff +from libnmstate.schema import DNS +from libnmstate.schema import Interface + + +class DnsState: + PRIORITY_METADATA = "_priority" + + def __init__(self, des_dns_state, cur_dns_state): + self._config_changed = False + if des_dns_state is None or des_dns_state.get(DNS.CONFIG) is None: + # Use current config if DNS.KEY not defined or DNS.CONFIG not + # defined. + self._dns_state = cur_dns_state or {} + else: + self._dns_state = des_dns_state + self._validate() + self._config_changed = _is_dns_config_changed( + des_dns_state, cur_dns_state + ) + self._cur_dns_state = deepcopy(cur_dns_state) if cur_dns_state else {} + + @property + def current_config(self): + return _get_config(self._cur_dns_state) + + @property + def config(self): + return _get_config(self._dns_state) + + @property + def _config_servers(self): + return _get_config_servers(self._dns_state) + + @property + def _config_searches(self): + return _get_config_searches(self._dns_state) + + def gen_metadata(self, ifaces, route_state): + """ + Return DNS configure targeting to store as metadata of interface. + Data structure returned is: + { + iface_name: { + Interface.IPV4: { + DNS.SERVER: dns_servers, + DNS.SEARCH: dns_searches, + }, + Interface.IPV6: { + DNS.SERVER: dns_servers, + DNS.SEARCH: dns_searches, + }, + } + } + """ + iface_metadata = {} + if not self._config_servers and not self._config_searches: + return iface_metadata + ipv4_iface, ipv6_iface = self._find_ifaces_for_name_servers( + ifaces, route_state + ) + if ipv4_iface == ipv6_iface: + iface_metadata = { + ipv4_iface: { + Interface.IPV4: {DNS.SERVER: [], DNS.SEARCH: []}, + Interface.IPV6: {DNS.SERVER: [], DNS.SEARCH: []}, + }, + } + else: + if ipv4_iface: + iface_metadata[ipv4_iface] = { + Interface.IPV4: {DNS.SERVER: [], DNS.SEARCH: []}, + } + if ipv6_iface: + iface_metadata[ipv6_iface] = { + Interface.IPV6: {DNS.SERVER: [], DNS.SEARCH: []}, + } + index = 0 + searches_saved = False + for server in self._config_servers: + iface_name = None + if is_ipv6_address(server): + iface_name = ipv6_iface + family = Interface.IPV6 + else: + iface_name = ipv4_iface + family = Interface.IPV4 + if not iface_name: + raise NmstateValueError( + "Failed to find suitable interface for saving DNS " + "name servers: %s" % server + ) + iface_dns_metada = iface_metadata[iface_name][family] + iface_dns_metada[DNS.SERVER].append(server) + iface_dns_metada.setdefault(DnsState.PRIORITY_METADATA, index) + if not searches_saved: + iface_dns_metada[DNS.SEARCH] = self._config_searches + searches_saved = True + index += 1 + return iface_metadata + + def _find_ifaces_for_name_servers(self, ifaces, route_state): + """ + Find interface to store the DNS configurations in the order of: + * Any interface with static gateway + * Any interface configured as dynamic IP with 'auto-dns:False' + Return tuple: (ipv4_iface, ipv6_iface) + """ + ipv4_iface, ipv6_iface = self._find_ifaces_with_static_gateways( + route_state + ) + if not (ipv4_iface and ipv6_iface): + ( + auto_ipv4_iface, + auto_ipv6_iface, + ) = self._find_ifaces_with_auto_dns_false(ifaces) + if not ipv4_iface and auto_ipv4_iface: + ipv4_iface = auto_ipv4_iface + if not ipv6_iface and auto_ipv6_iface: + ipv6_iface = auto_ipv6_iface + + return ipv4_iface, ipv6_iface + + def _find_ifaces_with_static_gateways(self, route_state): + """ + Return tuple of interfaces with IPv4 and IPv6 static gateways. + """ + ipv4_iface = None + ipv6_iface = None + for iface_name, route_set in route_state.config_iface_routes.items(): + for route in route_set: + if ipv4_iface and ipv6_iface: + return (ipv4_iface, ipv6_iface) + if route.is_gateway: + if route.is_ipv6: + ipv6_iface = iface_name + else: + ipv4_iface = iface_name + return (ipv4_iface, ipv6_iface) + + def _find_ifaces_with_auto_dns_false(self, ifaces): + ipv4_iface = None + ipv6_iface = None + for iface in ifaces.values(): + if ipv4_iface and ipv6_iface: + return (ipv4_iface, ipv6_iface) + for family in (Interface.IPV4, Interface.IPV6): + ip_state = iface.ip_state(family) + if ip_state.is_dynamic and (not ip_state.auto_dns): + if family == Interface.IPV4: + ipv4_iface = iface.name + else: + ipv6_iface = iface.name + + return (ipv4_iface, ipv6_iface) + + def verify(self, cur_dns_state): + cur_dns = DnsState(des_dns_state=None, cur_dns_state=cur_dns_state,) + if self.config.get(DNS.SERVER, []) != cur_dns.config.get( + DNS.SERVER, [] + ) or self.config.get(DNS.SEARCH, []) != cur_dns.config.get( + DNS.SEARCH, [] + ): + raise NmstateVerificationError( + format_desired_current_state_diff( + {DNS.KEY: self.config}, {DNS.KEY: cur_dns.config}, + ) + ) + + def _validate(self): + if ( + len(self._config_servers) > 2 + and any(is_ipv6_address(n) for n in self._config_servers) + and any(not is_ipv6_address(n) for n in self._config_servers) + ): + raise NmstateNotImplementedError( + "Three or more nameservers are only supported when using " + "either IPv4 or IPv6 nameservers but not both." + ) + + @property + def config_changed(self): + return self._config_changed + + +def _get_config(state): + conf = state.get(DNS.CONFIG, {}) + if not conf: + conf = {DNS.SERVER: [], DNS.SEARCH: []} + return conf + + +def _get_config_servers(state): + return _get_config(state).get(DNS.SERVER, []) + + +def _get_config_searches(state): + return _get_config(state).get(DNS.SEARCH, []) + + +def _is_dns_config_changed(des_dns_state, cur_dns_state): + return _get_config_servers(des_dns_state) != _get_config_servers( + cur_dns_state + ) or _get_config_searches(des_dns_state) != _get_config_searches( + cur_dns_state + ) diff --git a/libnmstate/error.py b/libnmstate/error.py new file mode 100644 index 0000000..1fd9c75 --- /dev/null +++ b/libnmstate/error.py @@ -0,0 +1,131 @@ +# +# Copyright (c) 2019-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + + +class NmstateError(Exception): + """ + The base exception of libnmstate. + """ + + pass + + +class NmstateDependencyError(NmstateError): + """ + Nmstate requires external tools installed and/or started for desired state. + """ + + pass + + +class NmstateValueError(NmstateError, ValueError): + """ + Exception happens at pre-apply check, user should resubmit the amended + desired state. Example: + * JSON/YAML syntax issue. + * Nmstate schema issue. + * Invalid value of desired property, like bond missing slave. + """ + + pass + + +class NmstatePermissionError(NmstateError, PermissionError): + """ + Permission deny when applying the desired state. + """ + + pass + + +class NmstateConflictError(NmstateError, RuntimeError): + """ + Something else is already editing the network state via Nmstate. + """ + + pass + + +class NmstateLibnmError(NmstateError): + """ + Exception for unexpected libnm failure. + """ + + pass + + +class NmstateVerificationError(NmstateError): + """ + After applied desired state, current state does not match desired state for + unknown reason. + """ + + pass + + +class NmstateKernelIntegerRoundedError(NmstateVerificationError): + """ + After applied desired state, current state does not match desire state + due to integer been rounded by kernel. + For example, with HZ configured as 250 in kernel, the linux bridge option + multicast_startup_query_interval, 3125 will be rounded to 3124. + """ + + pass + + +class NmstateNotImplementedError(NmstateError, NotImplementedError): + """ + Desired feature is not supported by Nmstate yet. + """ + + pass + + +class NmstateInternalError(NmstateError): + """ + Unexpected behaviour happened. It is a bug of libnmstate which should be + fixed. + """ + + pass + + +class NmstateNotSupportedError(NmstateError): + """ + A resource like a device does not support the requested feature. + """ + + pass + + +class NmstateTimeoutError(NmstateLibnmError): + """ + The transaction execution timed out. + """ + + pass + + +class NmstatePluginError(NmstateError): + """ + Unexpected plugin behaviour happens, it is a bug of the plugin. + """ + + pass diff --git a/libnmstate/ethtool.py b/libnmstate/ethtool.py new file mode 100644 index 0000000..1af310f --- /dev/null +++ b/libnmstate/ethtool.py @@ -0,0 +1,82 @@ +# +# Copyright (c) 2018-2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# +""" Minimal wrapper around the SIOCETHTOOL IOCTL to provide data that Network +Manager is not yet providing. + +https://bugzilla.redhat.com/show_bug.cgi?id=1621188 +""" + +import array +import struct +import fcntl +import socket + +ETHTOOL_GSET = 0x00000001 # Get settings +SIOCETHTOOL = 0x8946 + + +def minimal_ethtool(interface): + """ + Return dictionary with speed, duplex and auto-negotiation settings for the + specified interface using the ETHTOOL_GSET command. The speed is returned n + MBit/s, 0 means that the speed could not be determined. The duplex setting + is 'unknown', 'full' or 'half. The auto-negotiation setting True or False + or None if it could not be determined. + + Based on: + https://github.com/rlisagor/pynetlinux/blob/master/pynetlinux/ifconfig.py + https://elixir.bootlin.com/linux/v4.19-rc1/source/include/uapi/linux/ethtool.h + + :param interface str: Name of interface + :returns dict: Dictionary with the keys speed, duplex, auto-negotiation + + """ + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sockfd = sock.fileno() + + ecmd = array.array( + "B", struct.pack("I39s", ETHTOOL_GSET, b"\x00" * 39) + ) + + interface = interface.encode("utf-8") + ifreq = struct.pack("16sP", interface, ecmd.buffer_info()[0]) + + fcntl.ioctl(sockfd, SIOCETHTOOL, ifreq) + res = ecmd.tobytes() + speed, duplex, auto = struct.unpack("12xHB3xB24x", res) + except IOError: + speed, duplex, auto = 65535, 255, 255 + finally: + sock.close() + + if speed == 65535: + speed = 0 + + if duplex == 255: + duplex = "unknown" + else: + duplex = "full" if bool(duplex) else "half" + + if auto == 255: + auto = None + else: + auto = bool(auto) + + return {"speed": speed, "duplex": duplex, "auto-negotiation": auto} diff --git a/libnmstate/ifaces/__init__.py b/libnmstate/ifaces/__init__.py new file mode 100644 index 0000000..bf6c178 --- /dev/null +++ b/libnmstate/ifaces/__init__.py @@ -0,0 +1,24 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from .ifaces import Ifaces +from .base_iface import BaseIface + +Ifaces +BaseIface diff --git a/libnmstate/ifaces/base_iface.py b/libnmstate/ifaces/base_iface.py new file mode 100644 index 0000000..564d583 --- /dev/null +++ b/libnmstate/ifaces/base_iface.py @@ -0,0 +1,410 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from collections.abc import Mapping +from copy import deepcopy +import logging +from operator import itemgetter + +from libnmstate.error import NmstateInternalError +from libnmstate.error import NmstateValueError +from libnmstate.iplib import is_ipv6_link_local_addr +from libnmstate.iplib import canonicalize_ip_address +from libnmstate.schema import Interface +from libnmstate.schema import InterfaceIP +from libnmstate.schema import InterfaceIPv6 +from libnmstate.schema import InterfaceType +from libnmstate.schema import InterfaceState +from libnmstate.schema import LLDP + +from ..state import state_match +from ..state import merge_dict + + +class IPState: + def __init__(self, family, info): + self._family = family + self._info = info + self._remove_stack_if_disabled() + self._sort_addresses() + self._canonicalize_ip_addr() + self._canonicalize_dynamic() + + def _canonicalize_dynamic(self): + if self.is_enabled and self.is_dynamic: + self._info[InterfaceIP.ADDRESS] = [] + self._info.setdefault(InterfaceIP.AUTO_ROUTES, True) + self._info.setdefault(InterfaceIP.AUTO_DNS, True) + self._info.setdefault(InterfaceIP.AUTO_GATEWAY, True) + else: + for dhcp_option in ( + InterfaceIP.AUTO_ROUTES, + InterfaceIP.AUTO_GATEWAY, + InterfaceIP.AUTO_DNS, + ): + self._info.pop(dhcp_option, None) + + def _canonicalize_ip_addr(self): + for addr in self.addresses: + addr[InterfaceIP.ADDRESS_IP] = canonicalize_ip_address( + addr[InterfaceIP.ADDRESS_IP] + ) + + def _sort_addresses(self): + self.addresses.sort(key=itemgetter(InterfaceIP.ADDRESS_IP)) + + def _remove_stack_if_disabled(self): + if not self.is_enabled: + self._info = {InterfaceIP.ENABLED: False} + + @property + def is_enabled(self): + return self._info.get(InterfaceIP.ENABLED, False) + + @property + def is_dynamic(self): + return self._info.get(InterfaceIP.DHCP) or self._info.get( + InterfaceIPv6.AUTOCONF + ) + + @property + def auto_dns(self): + return self.is_dynamic and self._info.get(InterfaceIP.AUTO_DNS) + + @property + def addresses(self): + return self._info.get(InterfaceIP.ADDRESS, []) + + def to_dict(self): + return deepcopy(self._info) + + def validate(self, original): + if ( + self.is_enabled + and self.is_dynamic + and original.addresses + and self.addresses + ): + logging.warning( + f"Static addresses {original.addresses} " + "are ignored when dynamic IP is enabled" + ) + + def remove_link_local_address(self): + if self.addresses: + self._info[InterfaceIP.ADDRESS] = [ + addr + for addr in self.addresses + if not is_ipv6_link_local_addr( + addr[InterfaceIP.ADDRESS_IP], + addr[InterfaceIP.ADDRESS_PREFIX_LENGTH], + ) + ] + + +class BaseIface: + MASTER_METADATA = "_master" + MASTER_TYPE_METADATA = "_master_type" + DNS_METADATA = "_dns" + ROUTES_METADATA = "_routes" + ROUTE_RULES_METADATA = "_route_rules" + + def __init__(self, info, save_to_disk=True): + self._origin_info = deepcopy(info) + self._info = deepcopy(info) + self._is_desired = False + self._is_changed = False + self._name = self._info[Interface.NAME] + self._save_to_disk = save_to_disk + + @property + def can_have_ip_when_enslaved(self): + return False + + def sort_slaves(self): + pass + + @property + def raw(self): + """ + Internal use only: Allowing arbitrary modifcation. + """ + return self._info + + @property + def name(self): + return self._name + + @property + def type(self): + return self._info.get(Interface.TYPE, InterfaceType.UNKNOWN) + + @property + def state(self): + return self._info.get(Interface.STATE, InterfaceState.UP) + + @state.setter + def state(self, value): + self._info[Interface.STATE] = value + + @property + def is_desired(self): + return self._is_desired + + @property + def is_changed(self): + return self._is_changed + + def mark_as_changed(self): + self._is_changed = True + + def mark_as_desired(self): + self._is_desired = True + + def to_dict(self): + return deepcopy(self._info) + + @property + def original_dict(self): + return self._origin_info + + def ip_state(self, family): + return IPState(family, self._info.get(family, {})) + + def is_ipv4_enabled(self): + return self.ip_state(Interface.IPV4).is_enabled + + def is_ipv6_enabled(self): + return self.ip_state(Interface.IPV6).is_enabled + + def is_dynamic(self, family): + return self.ip_state(family).is_dynamic + + def pre_edit_validation_and_cleanup(self): + """ + This function is called after metadata generation finished. + Will do + * Raise NmstateValueError when user desire(self.original_dict) is + illegal. + * Clean up illegal setting introduced by merging. + We don't split validation from clean up as they might sharing the same + check code. + """ + if self.is_desired: + for family in (Interface.IPV4, Interface.IPV6): + self.ip_state(family).validate( + IPState(family, self._origin_info.get(family, {})) + ) + self._validate_slave_ip() + ip_state = self.ip_state(family) + ip_state.remove_link_local_address() + self._info[family] = ip_state.to_dict() + if self.is_absent and not self._save_to_disk: + self._info[Interface.STATE] = InterfaceState.DOWN + + def merge(self, other): + merge_dict(self._info, other._info) + # If down state is not from orignal state, set it as UP. + if ( + Interface.STATE not in self._origin_info + and self.state == InterfaceState.DOWN + ): + self._info[Interface.STATE] = InterfaceState.UP + + def _validate_slave_ip(self): + for family in (Interface.IPV4, Interface.IPV6): + ip_state = IPState(family, self._origin_info.get(family, {})) + if ( + ip_state.is_enabled + and self.master + and not self.can_have_ip_when_enslaved + ): + raise NmstateValueError( + f"Interface {self.name} is enslaved by {self.master_type} " + f"interface {self.master} which does not allow " + f"slaves to have {family} enabled" + ) + + @property + def slaves(self): + return [] + + @property + def parent(self): + return None + + @property + def need_parent(self): + return False + + @property + def is_absent(self): + return self.state == InterfaceState.ABSENT + + @property + def is_up(self): + return self.state == InterfaceState.UP + + @property + def is_down(self): + return self.state == InterfaceState.DOWN + + def mark_as_up(self): + self.raw[Interface.STATE] = InterfaceState.UP + + @property + def is_master(self): + return False + + def set_master(self, master_iface_name, master_type): + self._info[BaseIface.MASTER_METADATA] = master_iface_name + self._info[BaseIface.MASTER_TYPE_METADATA] = master_type + if not self.can_have_ip_when_enslaved: + for family in (Interface.IPV4, Interface.IPV6): + self._info[family] = {InterfaceIP.ENABLED: False} + + @property + def master(self): + return self._info.get(BaseIface.MASTER_METADATA) + + @property + def master_type(self): + return self._info.get(BaseIface.MASTER_TYPE_METADATA) + + def gen_metadata(self, ifaces): + if self.is_master and not self.is_absent: + for slave_name in self.slaves: + slave_iface = ifaces[slave_name] + slave_iface.set_master(self.name, self.type) + + def update(self, info): + self._info.update(info) + + @property + def mac(self): + return self._info.get(Interface.MAC) + + @property + def mtu(self): + return self._info.get(Interface.MTU) + + @mtu.setter + def mtu(self, value): + self._info[Interface.MTU] = value + + def _capitalize_mac(self): + if self.mac: + self._info[Interface.MAC] = self.mac.upper() + + def match(self, other): + self_state = self.state_for_verify() + other_state = other.state_for_verify() + return state_match(self_state, other_state) + + def state_for_verify(self): + """ + Return the network state as dictionary used for verifcation. + Clean up if required. + For BaseIface: + * Capitalize MAC addresses. + * Explicitly set state as UP if not defined. + * Remove IPv6 link local addresses. + * Remove empty description. + """ + self._capitalize_mac() + self.sort_slaves() + for family in (Interface.IPV4, Interface.IPV6): + ip_state = self.ip_state(family) + ip_state.remove_link_local_address() + self._info[family] = ip_state.to_dict() + state = self.to_dict() + _remove_empty_description(state) + _remove_undesired_data(state, self.original_dict) + _remove_lldp_neighbors(state) + if Interface.STATE not in state: + state[Interface.STATE] = InterfaceState.UP + if self.is_absent and not self._save_to_disk: + state[Interface.STATE] = InterfaceState.DOWN + + return state + + def remove_slave(self, slave_name): + if not self.is_master: + class_name = self.__class__.__name__ + raise NmstateInternalError( + f"Invalid invoke of {class_name}.remove_slave({slave_name}) " + f"as {class_name} is not a master interface" + ) + + @property + def is_virtual(self): + return False + + def create_virtual_slave(self, slave_name): + """ + When master interface has non-exist slave interface, master should + create virtual slave for this name if possible, or else return None + """ + return None + + def config_changed_slaves(self, _cur_iface): + """ + Return a list of slave interface name which has configuration changed + compareing to cur_iface. + """ + return [] + + def store_dns_metadata(self, dns_metadata): + for family, dns_config in dns_metadata.items(): + self.raw[family][BaseIface.DNS_METADATA] = dns_config + + def remove_dns_metadata(self): + for family in (Interface.IPV4, Interface.IPV6): + self.raw.get(family, {}).pop(BaseIface.DNS_METADATA, None) + + def store_route_metadata(self, route_metadata): + for family, routes in route_metadata.items(): + self.raw[family][BaseIface.ROUTES_METADATA] = routes + + def store_route_rule_metadata(self, route_rule_metadata): + for family, rules in route_rule_metadata.items(): + self.raw[family][BaseIface.ROUTE_RULES_METADATA] = rules + + +def _remove_empty_description(state): + if state.get(Interface.DESCRIPTION) == "": + del state[Interface.DESCRIPTION] + + +def _remove_lldp_neighbors(state): + state.get(LLDP.CONFIG_SUBTREE, {}).pop(LLDP.NEIGHBORS_SUBTREE, None) + + +def _remove_undesired_data(state, desire): + """ + For any key not defined in `desire`, remove it from `state` + """ + key_to_remove = [] + for key, value in state.items(): + if key not in desire: + key_to_remove.append(key) + elif isinstance(value, Mapping): + _remove_undesired_data(value, desire[key]) + for key in key_to_remove: + state.pop(key) diff --git a/libnmstate/ifaces/bond.py b/libnmstate/ifaces/bond.py new file mode 100644 index 0000000..f327a1e --- /dev/null +++ b/libnmstate/ifaces/bond.py @@ -0,0 +1,250 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import contextlib +import logging + +from libnmstate.error import NmstateValueError +from libnmstate.schema import Bond +from libnmstate.schema import BondMode +from libnmstate.schema import Interface + +from .base_iface import BaseIface + + +class BondIface(BaseIface): + _MODE_CHANGE_METADATA = "_bond_mode_changed" + + def sort_slaves(self): + if self.slaves: + self.raw[Bond.CONFIG_SUBTREE][Bond.SLAVES].sort() + + def __init__(self, info, save_to_disk=True): + super().__init__(info, save_to_disk) + self._normalize_options_values() + self._fix_bond_option_arp_monitor() + + @property + def slaves(self): + return self.raw.get(Bond.CONFIG_SUBTREE, {}).get(Bond.SLAVES, []) + + @property + def is_master(self): + return True + + @property + def is_virtual(self): + return True + + @property + def bond_mode(self): + return self.raw.get(Bond.CONFIG_SUBTREE, {}).get(Bond.MODE) + + @property + def _bond_options(self): + return self.raw.get(Bond.CONFIG_SUBTREE, {}).get( + Bond.OPTIONS_SUBTREE, {} + ) + + @property + def is_bond_mode_changed(self): + return self.raw.get(BondIface._MODE_CHANGE_METADATA) is True + + def _set_bond_mode_changed_metadata(self, value): + self.raw[BondIface._MODE_CHANGE_METADATA] = value + + def _generate_bond_mode_change_metadata(self, ifaces): + if self.is_up: + cur_iface = ifaces.current_ifaces.get(self.name) + if cur_iface and self.bond_mode != cur_iface.bond_mode: + self._set_bond_mode_changed_metadata(True) + + def gen_metadata(self, ifaces): + super().gen_metadata(ifaces) + if not self.is_absent: + self._generate_bond_mode_change_metadata(ifaces) + + def pre_edit_validation_and_cleanup(self): + super().pre_edit_validation_and_cleanup() + if self.is_up: + self._discard_bond_option_when_mode_change() + self._validate_bond_mode() + self._fix_mac_restriced_mode() + self._validate_miimon_conflict_with_arp_interval() + + def _discard_bond_option_when_mode_change(self): + if self.is_bond_mode_changed: + logging.warning( + "Discarding all current bond options as interface " + f"{self.name} has bond mode changed" + ) + self.raw[Bond.CONFIG_SUBTREE][ + Bond.OPTIONS_SUBTREE + ] = self.original_dict.get(Bond.CONFIG_SUBTREE, {}).get( + Bond.OPTIONS_SUBTREE, {} + ) + self._normalize_options_values() + + def _validate_bond_mode(self): + if self.bond_mode is None: + raise NmstateValueError( + f"Bond interface {self.name} does not have bond mode defined" + ) + + def _fix_mac_restriced_mode(self): + if self.is_in_mac_restricted_mode: + if self.original_dict.get(Interface.MAC): + raise NmstateValueError( + "MAC address cannot be specified in bond interface along " + "with fail_over_mac active on active backup mode" + ) + else: + self.raw.pop(Interface.MAC, None) + + def _validate_miimon_conflict_with_arp_interval(self): + bond_options = self._bond_options + if bond_options.get("miimon") and bond_options.get("arp_interval"): + raise NmstateValueError( + "Bond option arp_interval is conflicting with miimon, " + "please disable one of them by setting to 0" + ) + + @staticmethod + def is_mac_restricted_mode(mode, bond_options): + return ( + mode == BondMode.ACTIVE_BACKUP + and bond_options.get("fail_over_mac") == "active" + ) + + @property + def is_in_mac_restricted_mode(self): + """ + Return True when Bond option does not allow MAC address defined. + In MAC restricted mode means: + Bond mode is BondMode.ACTIVE_BACKUP + Bond option "fail_over_mac" is active. + """ + return BondIface.is_mac_restricted_mode( + self.bond_mode, self._bond_options + ) + + def _normalize_options_values(self): + if self._bond_options: + normalized_options = {} + for option_name, option_value in self._bond_options.items(): + with contextlib.suppress(ValueError): + option_value = int(option_value) + option_value = _get_bond_named_option_value_by_id( + option_name, option_value + ) + normalized_options[option_name] = option_value + self._bond_options.update(normalized_options) + + def _fix_bond_option_arp_monitor(self): + """ + Adding 'arp_ip_target=""' when ARP monitor is disabled by + `arp_interval=0` + """ + if self._bond_options: + _include_arp_ip_target_explictly_when_disable( + self.raw[Bond.CONFIG_SUBTREE][Bond.OPTIONS_SUBTREE] + ) + + def state_for_verify(self): + state = super().state_for_verify() + if state.get(Bond.CONFIG_SUBTREE, {}).get(Bond.OPTIONS_SUBTREE): + _include_arp_ip_target_explictly_when_disable( + state[Bond.CONFIG_SUBTREE][Bond.OPTIONS_SUBTREE] + ) + return state + + def remove_slave(self, slave_name): + self.raw[Bond.CONFIG_SUBTREE][Bond.SLAVES] = [ + s for s in self.slaves if s != slave_name + ] + self.sort_slaves() + + +class _BondNamedOptions: + AD_SELECT = "ad_select" + ARP_ALL_TARGETS = "arp_all_targets" + ARP_VALIDATE = "arp_validate" + FAIL_OVER_MAC = "fail_over_mac" + LACP_RATE = "lacp_rate" + MODE = "mode" + PRIMARY_RESELECT = "primary_reselect" + XMIT_HASH_POLICY = "xmit_hash_policy" + + +_BOND_OPTIONS_NUMERIC_TO_NAMED_MAP = { + _BondNamedOptions.AD_SELECT: ("stable", "bandwidth", "count"), + _BondNamedOptions.ARP_ALL_TARGETS: ("any", "all"), + _BondNamedOptions.ARP_VALIDATE: ( + "none", + "active", + "backup", + "all", + "filter", + "filter_active", + "filter_backup", + ), + _BondNamedOptions.FAIL_OVER_MAC: ("none", "active", "follow"), + _BondNamedOptions.LACP_RATE: ("slow", "fast"), + _BondNamedOptions.MODE: ( + "balance-rr", + "active-backup", + "balance-xor", + "broadcast", + "802.3ad", + "balance-tlb", + "balance-alb", + ), + _BondNamedOptions.PRIMARY_RESELECT: ("always", "better", "failure"), + _BondNamedOptions.XMIT_HASH_POLICY: ( + "layer2", + "layer3+4", + "layer2+3", + "encap2+3", + "encap3+4", + ), +} + + +def _get_bond_named_option_value_by_id(option_name, option_id_value): + """ + Given an option name and its value, return a named option value + if it exists. + Return the same option value as inputted if: + - The option name has no dual named and id values. + - The option value is not numeric. + - The option value has no corresponding named value (not in range). + """ + option_value = _BOND_OPTIONS_NUMERIC_TO_NAMED_MAP.get(option_name) + if option_value: + with contextlib.suppress(ValueError, IndexError): + return option_value[int(option_id_value)] + return option_id_value + + +def _include_arp_ip_target_explictly_when_disable(bond_options): + if ( + bond_options.get("arp_interval") == 0 + and "arp_ip_target" not in bond_options + ): + bond_options["arp_ip_target"] = "" diff --git a/libnmstate/ifaces/bridge.py b/libnmstate/ifaces/bridge.py new file mode 100644 index 0000000..d82c8cf --- /dev/null +++ b/libnmstate/ifaces/bridge.py @@ -0,0 +1,132 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +# This file will hold the common code shared by linux bridge and ovs bridge. + +from operator import itemgetter + +from libnmstate.schema import Bridge +from libnmstate.schema import LinuxBridge + +from ..state import merge_dict +from .base_iface import BaseIface + +READ_ONLY_OPTIONS = [ + LinuxBridge.Options.HELLO_TIMER, + LinuxBridge.Options.GC_TIMER, +] + + +class BridgeIface(BaseIface): + BRPORT_OPTIONS_METADATA = "_brport_options" + + @property + def is_master(self): + return True + + @property + def is_virtual(self): + return True + + def sort_slaves(self): + if self.slaves: + self.raw[Bridge.CONFIG_SUBTREE][Bridge.PORT_SUBTREE].sort( + key=itemgetter(Bridge.Port.NAME) + ) + + @property + def _bridge_config(self): + return self.raw.get(Bridge.CONFIG_SUBTREE, {}) + + @property + def port_configs(self): + return self._bridge_config.get(Bridge.PORT_SUBTREE, []) + + def merge(self, other): + super().merge(other) + self._merge_bridge_ports(other) + + def _merge_bridge_ports(self, other): + """ + Given a bridge desired state, and it's current state, merges + those together. + + This extension of the interface merging mechanism simplifies the user's + life in scenarios where the user wants to partially update the bridge's + configuration - e.g. update only the bridge's port STP configuration - + since it enables the user to simply specify the updated values rather + than the full current state + the updated value. + """ + if self._bridge_config.get(Bridge.PORT_SUBTREE) == []: + # User explictly defined empty list for ports and expecting + # removal of all ports. + return + + other_indexed_ports = _index_port_configs(other.port_configs) + self_indexed_ports = _index_port_configs(self.port_configs) + + # When defined, user need to specify the whole list of ports. + for port_iface_name in ( + other_indexed_ports.keys() & self_indexed_ports.keys() + ): + merge_dict( + self_indexed_ports[port_iface_name], + other_indexed_ports[port_iface_name], + ) + self.raw[Bridge.CONFIG_SUBTREE][Bridge.PORT_SUBTREE] = list( + self_indexed_ports.values() + ) + + def pre_edit_validation_and_cleanup(self): + self.sort_slaves() + super().pre_edit_validation_and_cleanup() + + def state_for_verify(self): + self._normalize_linux_bridge_port_vlan() + self._remove_read_only_bridge_options() + state = super().state_for_verify() + return state + + def config_changed_slaves(self, cur_iface): + changed_slaves = [] + cur_indexed_ports = _index_port_configs(cur_iface.port_configs) + for port_config in self.port_configs: + port_name = port_config[Bridge.Port.NAME] + cur_port_config = cur_indexed_ports.get(port_name) + if cur_port_config != port_config: + changed_slaves.append(port_name) + return changed_slaves + + def _normalize_linux_bridge_port_vlan(self): + """ + Set LinuxBridge.Port.VLAN_SUBTREE as {} when not defined. + """ + for port_config in self.port_configs: + if not port_config.get(Bridge.Port.VLAN_SUBTREE): + port_config[Bridge.Port.VLAN_SUBTREE] = {} + + def _remove_read_only_bridge_options(self): + for key in READ_ONLY_OPTIONS: + self._bridge_config.get(LinuxBridge.OPTIONS_SUBTREE, {}).pop( + key, None + ) + + +def _index_port_configs(port_configs): + return {port[Bridge.Port.NAME]: port for port in port_configs} diff --git a/libnmstate/ifaces/dummy.py b/libnmstate/ifaces/dummy.py new file mode 100644 index 0000000..cd6255e --- /dev/null +++ b/libnmstate/ifaces/dummy.py @@ -0,0 +1,26 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from .base_iface import BaseIface + + +class DummyIface(BaseIface): + @property + def is_virtual(self): + return True diff --git a/libnmstate/ifaces/ethernet.py b/libnmstate/ifaces/ethernet.py new file mode 100644 index 0000000..b346c36 --- /dev/null +++ b/libnmstate/ifaces/ethernet.py @@ -0,0 +1,59 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from libnmstate.schema import Ethernet + +from .base_iface import BaseIface + + +class EthernetIface(BaseIface): + def merge(self, other): + """ + Given the other_state, update the ethernet interfaces state base on + the other_state ethernet interfaces data. + Usually the other_state represents the current state. + If auto-negotiation, speed and duplex settings are not provided, + but exist in the current state, they need to be set to None + to not override them with the values from the current settings + since the current settings are read from the device state and not + from the actual configuration. This makes it possible to distinguish + whether a user specified these values in the later configuration step. + """ + eth_conf = self._info.setdefault(Ethernet.CONFIG_SUBTREE, {}) + eth_conf.setdefault(Ethernet.AUTO_NEGOTIATION, None) + eth_conf.setdefault(Ethernet.SPEED, None) + eth_conf.setdefault(Ethernet.DUPLEX, None) + super().merge(other) + + def state_for_verify(self): + state = super().state_for_verify() + _capitalize_sriov_vf_mac(state) + return state + + +def _capitalize_sriov_vf_mac(state): + vfs = ( + state.get(Ethernet.CONFIG_SUBTREE, {}) + .get(Ethernet.SRIOV_SUBTREE, {}) + .get(Ethernet.SRIOV.VFS_SUBTREE, []) + ) + for vf in vfs: + vf_mac = vf.get(Ethernet.SRIOV.VFS.MAC_ADDRESS) + if vf_mac: + vf[Ethernet.SRIOV.VFS.MAC_ADDRESS] = vf_mac.upper() diff --git a/libnmstate/ifaces/ifaces.py b/libnmstate/ifaces/ifaces.py new file mode 100644 index 0000000..1ff4198 --- /dev/null +++ b/libnmstate/ifaces/ifaces.py @@ -0,0 +1,447 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import logging + +from libnmstate.error import NmstateKernelIntegerRoundedError +from libnmstate.error import NmstateValueError +from libnmstate.error import NmstateVerificationError +from libnmstate.prettystate import format_desired_current_state_diff +from libnmstate.schema import Interface +from libnmstate.schema import InterfaceType +from libnmstate.schema import InterfaceState + +from .base_iface import BaseIface +from .bond import BondIface +from .dummy import DummyIface +from .ethernet import EthernetIface +from .linux_bridge import LinuxBridgeIface +from .ovs import OvsBridgeIface +from .ovs import OvsInternalIface +from .team import TeamIface +from .vlan import VlanIface +from .vxlan import VxlanIface + + +class Ifaces: + """ + The Ifaces class hold both desired state(optional) and current state. + When desire state been provided, will perpare the state for backend plugin + to apply with: + * Validating on original desire state. + * Merging state. + * Generating metadata. + The class itself is focusing on tasks related to inter-interfaces changes: + * Mater/slave interfaces. + * Parent/Child interfaces. + The class is maitnaing a list of BaseIface(or its child classes) which does + not know desire state and current state difference. Hence this class is + also responsible to handle desire vs current state related tasks. + """ + + def __init__(self, des_iface_infos, cur_iface_infos, save_to_disk=True): + self._save_to_disk = save_to_disk + self._des_iface_infos = des_iface_infos + self._cur_ifaces = {} + self._ifaces = {} + if cur_iface_infos: + for iface_info in cur_iface_infos: + cur_iface = _to_specific_iface_obj(iface_info, save_to_disk) + self._ifaces[cur_iface.name] = cur_iface + self._cur_ifaces[cur_iface.name] = cur_iface + + if des_iface_infos: + for iface_info in des_iface_infos: + iface = BaseIface(iface_info, save_to_disk) + cur_iface = self._ifaces.get(iface.name) + if cur_iface and cur_iface.is_desired: + raise NmstateValueError( + f"Duplicate interfaces names detected: {iface.name}" + ) + + if iface_info.get(Interface.TYPE) is None: + if cur_iface: + iface_info[Interface.TYPE] = cur_iface.type + elif iface.is_up: + raise NmstateValueError( + f"Interface {iface.name} has no type defined " + "neither in desire state nor current state" + ) + iface = _to_specific_iface_obj(iface_info, save_to_disk) + if ( + iface.type == InterfaceType.UNKNOWN + # Allowing deletion of down profiles + and not iface.is_absent + ): + # Ignore interface with unknown type + continue + if cur_iface: + iface.merge(cur_iface) + iface.mark_as_desired() + self._ifaces[iface.name] = iface + + self._create_virtual_slaves() + self._validate_unknown_slaves() + self._validate_unknown_parent() + self._gen_metadata() + for iface in self._ifaces.values(): + iface.pre_edit_validation_and_cleanup() + + self._pre_edit_validation_and_cleanup() + + def _create_virtual_slaves(self): + """ + Certain master interface could have virtual slaves which does not + defined in desired state. Create it before generating metadata. + For example, OVS bridge could have slave defined as OVS internal + interface which could be created without defining in desire state but + only in slave list of OVS bridge. + """ + new_ifaces = [] + for iface in self._ifaces.values(): + if iface.is_up and iface.is_master: + for slave_name in iface.slaves: + if slave_name not in self._ifaces.keys(): + new_slave = iface.create_virtual_slave(slave_name) + if new_slave: + new_ifaces.append(new_slave) + for iface in new_ifaces: + self._ifaces[iface.name] = iface + + def _pre_edit_validation_and_cleanup(self): + self._validate_over_booked_slaves() + self._validate_vlan_mtu() + self._handle_master_slave_list_change() + self._match_child_iface_state_with_parent() + self._mark_orphen_as_absent() + self._bring_slave_up_if_not_in_desire() + self._validate_ovs_patch_peers() + self._remove_unknown_type_interfaces() + + def _bring_slave_up_if_not_in_desire(self): + """ + When slave been included in master, automactially set it as state UP + if not defiend in desire state + """ + for iface in self._ifaces.values(): + if iface.is_up and iface.is_master: + for slave_name in iface.slaves: + slave_iface = self._ifaces[slave_name] + if not slave_iface.is_desired and not slave_iface.is_up: + slave_iface.mark_as_up() + slave_iface.mark_as_changed() + + def _validate_ovs_patch_peers(self): + """ + When OVS patch peer does not exist or is down, raise an error. + """ + for iface in self._ifaces.values(): + if iface.type == InterfaceType.OVS_INTERFACE and iface.is_up: + if iface.peer: + peer_iface = self._ifaces.get(iface.peer) + if not peer_iface or not peer_iface.is_up: + raise NmstateValueError( + f"OVS patch port peer {iface.peer} must exist and " + "be up" + ) + elif ( + not peer_iface.type == InterfaceType.OVS_INTERFACE + or not peer_iface.is_patch_port + ): + raise NmstateValueError( + f"OVS patch port peer {iface.peer} must be an OVS" + " patch port" + ) + + def _validate_vlan_mtu(self): + """ + Validate that mtu of vlan or vxlan is less than + or equal to it's base interface's MTU + + If base MTU is not present, set same as vlan MTU + """ + for iface in self._ifaces.values(): + + if ( + iface.type in [InterfaceType.VLAN, InterfaceType.VXLAN] + and iface.is_up + and iface.mtu + ): + base_iface = self._ifaces.get(iface.parent) + if not base_iface.mtu: + base_iface.mtu = iface.mtu + if iface.mtu > base_iface.mtu: + raise NmstateValueError( + f"Interface {iface.name} has bigger " + f"MTU({iface.mtu}) " + f"than its base interface: {iface.parent} " + f"MTU({base_iface.mtu})" + ) + + def _handle_master_slave_list_change(self): + """ + * Mark slave interface as changed if master removed. + * Mark slave interface as changed if slave list of master changed. + * Mark slave interface as changed if slave config changed when master + said so. + """ + for iface in self._ifaces.values(): + if not iface.is_desired or not iface.is_master: + continue + des_slaves = set(iface.slaves) + if iface.is_absent: + des_slaves = set() + cur_iface = self._cur_ifaces.get(iface.name) + cur_slaves = set(cur_iface.slaves) if cur_iface else set() + if des_slaves != cur_slaves: + changed_slaves = (des_slaves | cur_slaves) - ( + des_slaves & cur_slaves + ) + for iface_name in changed_slaves: + self._ifaces[iface_name].mark_as_changed() + if cur_iface: + for slave_name in iface.config_changed_slaves(cur_iface): + self._ifaces[slave_name].mark_as_changed() + + def _match_child_iface_state_with_parent(self): + """ + Handles these use cases: + * When changed/desired parent interface is up, child is not + desired to be any state, set child as UP. + * When changed/desired parent interface is marked as down or + absent, child state should sync with parent. + """ + for iface in self._ifaces.values(): + if iface.parent and self._ifaces.get(iface.parent): + parent_iface = self._ifaces[iface.parent] + if parent_iface.is_desired or parent_iface.is_changed: + if ( + Interface.STATE not in iface.original_dict + or parent_iface.is_down + or parent_iface.is_absent + ): + iface.state = parent_iface.state + iface.mark_as_changed() + + def _mark_orphen_as_absent(self): + for iface in self._ifaces.values(): + if iface.need_parent and ( + not iface.parent or not self._ifaces.get(iface.parent) + ): + iface.mark_as_changed() + iface.state = InterfaceState.ABSENT + + def get(self, iface_name): + return self._ifaces.get(iface_name) + + def __getitem__(self, iface_name): + return self._ifaces[iface_name] + + def __setitem__(self, iface_name, iface): + self._ifaces[iface_name] = iface + + def _gen_metadata(self): + for iface in self._ifaces.values(): + # Generate metadata for all interface in case any of them + # been marked as changed by DNS/Route/RouteRule. + iface.gen_metadata(self) + + def keys(self): + for iface in self._ifaces.keys(): + yield iface + + def values(self): + for iface in self._ifaces.values(): + yield iface + + @property + def current_ifaces(self): + return self._cur_ifaces + + @property + def state_to_edit(self): + return [ + iface.to_dict() + for iface in self._ifaces.values() + if iface.is_changed or iface.is_desired + ] + + @property + def cur_ifaces(self): + return self._cur_ifaces + + def _remove_unmanaged_slaves(self): + """ + When master containing unmanaged slaves, they should be removed from + master slave list. + """ + for iface in self._ifaces.values(): + if iface.is_up and iface.is_master and iface.slaves: + for slave_name in iface.slaves: + slave_iface = self._ifaces[slave_name] + if not slave_iface.is_up: + iface.remove_slave(slave_name) + + def verify(self, cur_iface_infos): + cur_ifaces = Ifaces( + des_iface_infos=None, + cur_iface_infos=cur_iface_infos, + save_to_disk=self._save_to_disk, + ) + for iface in self._ifaces.values(): + if iface.is_desired: + if iface.is_virtual and iface.original_dict.get( + Interface.STATE + ) in (InterfaceState.DOWN, InterfaceState.ABSENT): + cur_iface = cur_ifaces.get(iface.name) + if cur_iface: + raise NmstateVerificationError( + format_desired_current_state_diff( + iface.original_dict, + cur_iface.state_for_verify(), + ) + ) + elif iface.is_up or (iface.is_down and not iface.is_virtual): + cur_iface = cur_ifaces.get(iface.name) + if not cur_iface: + raise NmstateVerificationError( + format_desired_current_state_diff( + iface.original_dict, {} + ) + ) + elif not iface.match(cur_iface): + if iface.type == InterfaceType.LINUX_BRIDGE: + ( + key, + value, + cur_value, + ) = LinuxBridgeIface.is_integer_rounded( + iface, cur_iface + ) + if key: + raise NmstateKernelIntegerRoundedError( + "Linux kernel configured with 250 HZ " + "will round up/down the integer in linux " + f"bridge {iface.name} option '{key}' " + f"from {value} to {cur_value}." + ) + raise NmstateVerificationError( + format_desired_current_state_diff( + iface.state_for_verify(), + cur_iface.state_for_verify(), + ) + ) + + def gen_dns_metadata(self, dns_state, route_state): + iface_metadata = dns_state.gen_metadata(self, route_state) + for iface_name, dns_metadata in iface_metadata.items(): + self._ifaces[iface_name].store_dns_metadata(dns_metadata) + if dns_state.config_changed: + self._ifaces[iface_name].mark_as_changed() + + def gen_route_metadata(self, route_state): + iface_metadata = route_state.gen_metadata(self) + for iface_name, route_metadata in iface_metadata.items(): + self._ifaces[iface_name].store_route_metadata(route_metadata) + + def gen_route_rule_metadata(self, route_rule_state, route_state): + iface_metadata = route_rule_state.gen_metadata(route_state) + for iface_name, route_rule_metadata in iface_metadata.items(): + self._ifaces[iface_name].store_route_rule_metadata( + route_rule_metadata + ) + if route_rule_state.config_changed: + self._ifaces[iface_name].mark_as_changed() + + def _validate_unknown_slaves(self): + """ + Check the existance of slave interface + """ + for iface in self._ifaces.values(): + for slave_name in iface.slaves: + if not self._ifaces.get(slave_name): + raise NmstateValueError( + f"Interface {iface.name} has unknown slave: " + f"{slave_name}" + ) + + def _validate_unknown_parent(self): + """ + Check the existance of parent interface + """ + for iface in self._ifaces.values(): + if iface.parent and not self._ifaces.get(iface.parent): + raise NmstateValueError( + f"Interface {iface.name} has unknown parent: " + f"{iface.parent}" + ) + + def _remove_unknown_type_interfaces(self): + """ + Remove unknown type interfaces that are set as up. + """ + for iface in list(self._ifaces.values()): + if iface.type == InterfaceType.UNKNOWN and iface.is_up: + self._ifaces.pop(iface.name, None) + logging.debug( + f"Interface {iface.name} is type {iface.type} and " + "will be ignored during the activation" + ) + + def _validate_over_booked_slaves(self): + """ + Check whether any slave is used by more than one master + """ + slave_master_map = {} + for iface in self._ifaces.values(): + for slave_name in iface.slaves: + cur_master = slave_master_map.get(slave_name) + if cur_master: + cur_master_iface = self._ifaces.get(cur_master) + if cur_master_iface and not cur_master_iface.is_absent: + raise NmstateValueError( + f"Interface {iface.name} slave {slave_name} is " + f"already enslaved by interface {cur_master}" + ) + else: + slave_master_map[slave_name] = iface.name + + +def _to_specific_iface_obj(info, save_to_disk): + iface_type = info.get(Interface.TYPE, InterfaceType.UNKNOWN) + if iface_type == InterfaceType.ETHERNET: + return EthernetIface(info, save_to_disk) + elif iface_type == InterfaceType.BOND: + return BondIface(info, save_to_disk) + elif iface_type == InterfaceType.DUMMY: + return DummyIface(info, save_to_disk) + elif iface_type == InterfaceType.LINUX_BRIDGE: + return LinuxBridgeIface(info, save_to_disk) + elif iface_type == InterfaceType.OVS_BRIDGE: + return OvsBridgeIface(info, save_to_disk) + elif iface_type == InterfaceType.OVS_INTERFACE: + return OvsInternalIface(info, save_to_disk) + elif iface_type == InterfaceType.VLAN: + return VlanIface(info, save_to_disk) + elif iface_type == InterfaceType.VXLAN: + return VxlanIface(info, save_to_disk) + elif iface_type == InterfaceType.TEAM: + return TeamIface(info, save_to_disk) + else: + return BaseIface(info, save_to_disk) diff --git a/libnmstate/ifaces/linux_bridge.py b/libnmstate/ifaces/linux_bridge.py new file mode 100644 index 0000000..c92863f --- /dev/null +++ b/libnmstate/ifaces/linux_bridge.py @@ -0,0 +1,237 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from libnmstate.error import NmstateValueError +from libnmstate.schema import LinuxBridge + +from .bridge import BridgeIface + +# The aging_time, forward_delay, hello_time, max_age options are multipled by +# 100(USER_HZ) when apply to kernel(via NM), so they are not impacted by this +# integer round up/down issue. +INTEGER_ROUNDED_OPTIONS = [ + LinuxBridge.Options.MULTICAST_LAST_MEMBER_INTERVAL, + LinuxBridge.Options.MULTICAST_MEMBERSHIP_INTERVAL, + LinuxBridge.Options.MULTICAST_QUERIER_INTERVAL, + LinuxBridge.Options.MULTICAST_QUERY_RESPONSE_INTERVAL, + LinuxBridge.Options.MULTICAST_STARTUP_QUERY_INTERVAL, +] + + +class LinuxBridgeIface(BridgeIface): + @property + def _options(self): + return self.raw.get(LinuxBridge.CONFIG_SUBTREE, {}).get( + LinuxBridge.OPTIONS_SUBTREE, {} + ) + + def pre_edit_validation_and_cleanup(self): + self._validate() + self._fix_vlan_filtering_mode() + super().pre_edit_validation_and_cleanup() + + @property + def slaves(self): + return [p[LinuxBridge.Port.NAME] for p in self.port_configs] + + def _validate(self): + self._validate_vlan_filtering_trunk_tags() + self._validate_vlan_filtering_tag() + self._validate_vlan_filtering_enable_native() + + def _validate_vlan_filtering_trunk_tags(self): + for port_config in self.original_dict.get( + LinuxBridge.CONFIG_SUBTREE, {} + ).get(LinuxBridge.PORT_SUBTREE, []): + port_vlan_state = port_config.get( + LinuxBridge.Port.VLAN_SUBTREE, {} + ) + vlan_mode = port_vlan_state.get(LinuxBridge.Port.Vlan.MODE) + trunk_tags = port_vlan_state.get( + LinuxBridge.Port.Vlan.TRUNK_TAGS, [] + ) + + if vlan_mode == LinuxBridge.Port.Vlan.Mode.ACCESS: + if trunk_tags: + raise NmstateValueError( + "Access port cannot have trunk tags" + ) + elif port_vlan_state: + if not trunk_tags: + raise NmstateValueError( + "A trunk port needs to specify trunk tags" + ) + for trunk_tag in trunk_tags: + _assert_vlan_filtering_trunk_tag(trunk_tag) + + def _validate_vlan_filtering_tag(self): + """ + The "tag" is valid in access mode or tunk mode with + "enable-native:True". + """ + for port_config in self.original_dict.get( + LinuxBridge.CONFIG_SUBTREE, {} + ).get(LinuxBridge.PORT_SUBTREE, []): + vlan_config = _get_port_vlan_config(port_config) + if ( + vlan_config.get(LinuxBridge.Port.Vlan.TAG) + and _vlan_is_trunk_mode(vlan_config) + and not _vlan_is_enable_native(vlan_config) + ): + raise NmstateValueError( + "Tag cannot be use in trunk mode without enable-native" + ) + + def _validate_vlan_filtering_enable_native(self): + for port_config in self.original_dict.get( + LinuxBridge.CONFIG_SUBTREE, {} + ).get(LinuxBridge.PORT_SUBTREE, []): + vlan_config = _get_port_vlan_config(port_config) + if _vlan_is_access_mode(vlan_config) and _vlan_is_enable_native( + vlan_config + ): + raise NmstateValueError( + "enable-native cannot be set in access mode" + ) + + def _fix_vlan_filtering_mode(self): + for port_config in self.port_configs: + _vlan_config_clean_up(_get_port_vlan_config(port_config)) + + def gen_metadata(self, ifaces): + super().gen_metadata(ifaces) + if not self.is_absent: + for port_config in self.port_configs: + ifaces[port_config[LinuxBridge.Port.NAME]].update( + {BridgeIface.BRPORT_OPTIONS_METADATA: port_config} + ) + + def remove_slave(self, slave_name): + if self._bridge_config: + self.raw[LinuxBridge.CONFIG_SUBTREE][LinuxBridge.PORT_SUBTREE] = [ + port_config + for port_config in self.port_configs + if port_config[LinuxBridge.Port.NAME] != slave_name + ] + self.sort_slaves() + + @staticmethod + def is_integer_rounded(iface_state, current_iface_state): + for key, value in iface_state._options.items(): + if key in INTEGER_ROUNDED_OPTIONS: + try: + value = int(value) + cur_value = int(current_iface_state._options.get(key)) + except (TypeError, ValueError): + continue + # With 250 HZ and 100 USER_HZ, every 8,000,000 will have 1 + # deviation, caused by: + # * kernel set the value using clock_t_to_jiffies(): + # jiffies = int(clock * 100 / 250) + # * kernel showing the value using jiffies_to_clock_t(): + # clock = int(int(jiffies * ( (10 ** 9 + 250/2) / 250) + # / 10 ** 9 * 100) + # + # The number 8,000,000 is found by exhaustion. + # There is no good way to detect kernel HZ in user space. Hence + # we check whether certain value is rounded. + if cur_value != value: + if value >= 8 * (10 ** 6): + if abs(value - cur_value) <= int( + value / 8 * (10 ** 6) + ): + return key, value, cur_value + else: + if abs(value - cur_value) == 1: + return key, value, cur_value + + return None, None, None + + +def _assert_vlan_filtering_trunk_tag(trunk_tag_state): + vlan_id = trunk_tag_state.get(LinuxBridge.Port.Vlan.TrunkTags.ID) + vlan_id_range = trunk_tag_state.get( + LinuxBridge.Port.Vlan.TrunkTags.ID_RANGE + ) + + if vlan_id and vlan_id_range: + raise NmstateValueError( + "Trunk port cannot be configured by both id and range: {}".format( + trunk_tag_state + ) + ) + elif vlan_id_range: + if not ( + { + LinuxBridge.Port.Vlan.TrunkTags.MIN_RANGE, + LinuxBridge.Port.Vlan.TrunkTags.MAX_RANGE, + } + <= set(vlan_id_range) + ): + raise NmstateValueError( + "Trunk port range requires min / max keys: {}".format( + vlan_id_range + ) + ) + + +def _get_port_vlan_config(port_config): + return port_config.get(LinuxBridge.Port.VLAN_SUBTREE, {}) + + +# TODO: Group them into class _LinuxBridgePort +def _vlan_is_access_mode(vlan_config): + return ( + vlan_config.get(LinuxBridge.Port.Vlan.MODE) + == LinuxBridge.Port.Vlan.Mode.ACCESS + ) + + +def _vlan_is_trunk_mode(vlan_config): + return ( + vlan_config.get(LinuxBridge.Port.Vlan.MODE) + == LinuxBridge.Port.Vlan.Mode.TRUNK + ) + + +def _vlan_is_enable_native(vlan_config): + return vlan_config.get(LinuxBridge.Port.Vlan.ENABLE_NATIVE) is True + + +def _vlan_config_clean_up(vlan_config): + _vlan_remove_enable_native_if_access_mode(vlan_config) + _vlan_remove_tag_if_trunk_mode_without_enable_native(vlan_config) + _vlan_remove_trunk_tag_if_access_mode(vlan_config) + + +def _vlan_remove_enable_native_if_access_mode(vlan_config): + if _vlan_is_access_mode(vlan_config): + vlan_config.pop(LinuxBridge.Port.Vlan.ENABLE_NATIVE, None) + + +def _vlan_remove_tag_if_trunk_mode_without_enable_native(vlan_config): + if _vlan_is_trunk_mode(vlan_config) and not _vlan_is_enable_native( + vlan_config + ): + vlan_config.pop(LinuxBridge.Port.Vlan.TAG, None) + + +def _vlan_remove_trunk_tag_if_access_mode(vlan_config): + if _vlan_is_access_mode(vlan_config): + vlan_config.pop(LinuxBridge.Port.Vlan.TRUNK_TAGS, None) diff --git a/libnmstate/ifaces/ovs.py b/libnmstate/ifaces/ovs.py new file mode 100644 index 0000000..cd04cef --- /dev/null +++ b/libnmstate/ifaces/ovs.py @@ -0,0 +1,262 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from copy import deepcopy +from operator import itemgetter +import subprocess + +from libnmstate.error import NmstateValueError +from libnmstate.schema import Interface +from libnmstate.schema import InterfaceIP +from libnmstate.schema import InterfaceType +from libnmstate.schema import InterfaceState +from libnmstate.schema import OVSBridge +from libnmstate.schema import OVSInterface +from libnmstate.schema import OvsDB + +from .bridge import BridgeIface +from .base_iface import BaseIface + + +SYSTEMCTL_TIMEOUT_SECONDS = 5 + + +class OvsBridgeIface(BridgeIface): + @property + def _has_bond_port(self): + for port_config in self.port_configs: + if port_config.get(OVSBridge.Port.LINK_AGGREGATION_SUBTREE): + return True + return False + + def sort_slaves(self): + super().sort_slaves() + self._sort_bond_slaves() + + def _sort_bond_slaves(self): + # For slaves of ovs bond/link_aggregation + for port in self.port_configs: + lag = port.get(OVSBridge.Port.LINK_AGGREGATION_SUBTREE) + if lag: + lag.get( + OVSBridge.Port.LinkAggregation.SLAVES_SUBTREE, [] + ).sort( + key=itemgetter(OVSBridge.Port.LinkAggregation.Slave.NAME) + ) + + @property + def slaves(self): + slaves = [] + for port_config in self.port_configs: + lag = port_config.get(OVSBridge.Port.LINK_AGGREGATION_SUBTREE) + if lag: + lag_slaves = lag.get( + OVSBridge.Port.LinkAggregation.SLAVES_SUBTREE, [] + ) + name_key = OVSBridge.Port.LinkAggregation.Slave.NAME + slaves += [s[name_key] for s in lag_slaves] + else: + slaves.append(port_config[OVSBridge.Port.NAME]) + return slaves + + def gen_metadata(self, ifaces): + for slave_name in self.slaves: + slave_iface = ifaces[slave_name] + port_config = _lookup_ovs_port_by_interface( + self.port_configs, slave_iface.name + ) + slave_iface.update( + {BridgeIface.BRPORT_OPTIONS_METADATA: port_config} + ) + if slave_iface.type == InterfaceType.OVS_INTERFACE: + slave_iface.parent = self.name + super().gen_metadata(ifaces) + + def create_virtual_slave(self, slave_name): + """ + When slave does not exists in merged desire state, it means it's an + OVS internal interface, create it. + """ + slave_iface = OvsInternalIface( + { + Interface.NAME: slave_name, + Interface.TYPE: InterfaceType.OVS_INTERFACE, + Interface.STATE: InterfaceState.UP, + } + ) + slave_iface.mark_as_changed() + slave_iface.set_master(self.name, self.type) + slave_iface.parent = self.name + return slave_iface + + def pre_edit_validation_and_cleanup(self): + super().pre_edit_validation_and_cleanup() + self._validate_ovs_lag_slave_count() + + def _validate_ovs_lag_slave_count(self): + for port in self.port_configs: + slaves_subtree = OVSBridge.Port.LinkAggregation.SLAVES_SUBTREE + lag = port.get(OVSBridge.Port.LINK_AGGREGATION_SUBTREE) + if lag and len(lag.get(slaves_subtree, ())) < 2: + raise NmstateValueError( + f"OVS {self.name} LAG port {lag} has less than 2 slaves." + ) + + def remove_slave(self, slave_name): + new_port_configs = [] + for port in self.port_configs: + if port[OVSBridge.Port.NAME] == slave_name: + continue + lag = port.get(OVSBridge.Port.LINK_AGGREGATION_SUBTREE) + if lag: + new_port = deepcopy(port) + new_lag = new_port[OVSBridge.Port.LINK_AGGREGATION_SUBTREE] + lag_slaves = lag.get( + OVSBridge.Port.LinkAggregation.SLAVES_SUBTREE + ) + if lag_slaves: + name_key = OVSBridge.Port.LinkAggregation.Slave.NAME + new_lag[OVSBridge.Port.LinkAggregation.SLAVES_SUBTREE] = [ + s for s in lag_slaves if s[name_key] != slave_name + ] + new_port_configs.append(new_port) + else: + new_port_configs.append(port) + self.raw[OVSBridge.CONFIG_SUBTREE][ + OVSBridge.PORT_SUBTREE + ] = new_port_configs + self.sort_slaves() + + def state_for_verify(self): + state = super().state_for_verify() + _convert_external_ids_values_to_string(state) + return state + + +def _lookup_ovs_port_by_interface(ports, slave_name): + for port in ports: + lag_state = port.get(OVSBridge.Port.LINK_AGGREGATION_SUBTREE) + if lag_state and _is_ovs_lag_slave(lag_state, slave_name): + return port + elif port[OVSBridge.Port.NAME] == slave_name: + return port + return {} + + +def _is_ovs_lag_slave(lag_state, iface_name): + slaves = lag_state.get(OVSBridge.Port.LinkAggregation.SLAVES_SUBTREE, ()) + for slave in slaves: + if slave[OVSBridge.Port.LinkAggregation.Slave.NAME] == iface_name: + return True + return False + + +class OvsInternalIface(BaseIface): + def __init__(self, info, save_to_disk=True): + super().__init__(info, save_to_disk) + self._parent = None + + @property + def is_virtual(self): + return True + + @property + def can_have_ip_when_enslaved(self): + return True + + @property + def parent(self): + return self._parent + + @parent.setter + def parent(self, value): + self._parent = value + + @property + def need_parent(self): + return True + + @property + def patch_config(self): + return self._info.get(OVSInterface.PATCH_CONFIG_SUBTREE) + + def state_for_verify(self): + state = super().state_for_verify() + _convert_external_ids_values_to_string(state) + return state + + @property + def is_patch_port(self): + return self.patch_config and self.patch_config.get( + OVSInterface.Patch.PEER + ) + + @property + def peer(self): + return ( + self.patch_config.get(OVSInterface.Patch.PEER) + if self.patch_config + else None + ) + + def pre_edit_validation_and_cleanup(self): + super().pre_edit_validation_and_cleanup() + self._validate_ovs_mtu_mac_confliction() + + def _validate_ovs_mtu_mac_confliction(self): + if self.is_patch_port: + if ( + self.original_dict.get(Interface.IPV4, {}).get( + InterfaceIP.ENABLED + ) + or self.original_dict.get(Interface.IPV6, {}).get( + InterfaceIP.ENABLED + ) + or self.original_dict.get(Interface.MTU) + or self.original_dict.get(Interface.MAC) + ): + raise NmstateValueError( + "OVS Patch interface cannot contain MAC address, MTU" + " or IP configuration." + ) + else: + self._info.pop(Interface.MTU, None) + self._info.pop(Interface.MAC, None) + + +def is_ovs_running(): + try: + subprocess.run( + ("systemctl", "status", "openvswitch"), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + timeout=SYSTEMCTL_TIMEOUT_SECONDS, + ) + return True + except Exception: + return False + + +def _convert_external_ids_values_to_string(iface_info): + external_ids = iface_info.get(OvsDB.OVS_DB_SUBTREE, {}).get( + OvsDB.EXTERNAL_IDS, {} + ) + for key, value in external_ids.items(): + external_ids[key] = str(value) diff --git a/libnmstate/ifaces/team.py b/libnmstate/ifaces/team.py new file mode 100644 index 0000000..71f5d41 --- /dev/null +++ b/libnmstate/ifaces/team.py @@ -0,0 +1,55 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from operator import itemgetter + +from libnmstate.schema import Team + +from .base_iface import BaseIface + + +class TeamIface(BaseIface): + @property + def slaves(self): + ports = self.raw.get(Team.CONFIG_SUBTREE, {}).get( + Team.PORT_SUBTREE, [] + ) + return [p[Team.Port.NAME] for p in ports] + + @property + def is_master(self): + return True + + @property + def is_virtual(self): + return True + + def sort_slaves(self): + if self.slaves: + self.raw[Team.CONFIG_SUBTREE][Team.PORT_SUBTREE].sort( + key=itemgetter(Team.Port.NAME) + ) + + def remove_slave(self, slave_name): + if self.slaves: + slaves_config = self.raw[Team.CONFIG_SUBTREE][Team.PORT_SUBTREE] + self.raw[Team.CONFIG_SUBTREE][Team.PORT_SUBTREE] = [ + s for s in slaves_config if s[Team.Port.NAME] != slave_name + ] + self.sort_slaves() diff --git a/libnmstate/ifaces/vlan.py b/libnmstate/ifaces/vlan.py new file mode 100644 index 0000000..c71e0e8 --- /dev/null +++ b/libnmstate/ifaces/vlan.py @@ -0,0 +1,58 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from libnmstate.error import NmstateValueError +from libnmstate.schema import VLAN + +from .base_iface import BaseIface + + +class VlanIface(BaseIface): + @property + def parent(self): + return self._vlan_config.get(VLAN.BASE_IFACE) + + @property + def need_parent(self): + return True + + @property + def _vlan_config(self): + return self.raw.get(VLAN.CONFIG_SUBTREE, {}) + + @property + def is_virtual(self): + return True + + @property + def can_have_ip_when_enslaved(self): + return True + + def pre_edit_validation_and_cleanup(self): + self._validate_mandatory_properties() + super().pre_edit_validation_and_cleanup() + + def _validate_mandatory_properties(self): + if self.is_up: + for prop in (VLAN.ID, VLAN.BASE_IFACE): + if prop not in self._vlan_config: + raise NmstateValueError( + f"VLAN tunnel {self.name} has missing mandatory " + f"property: {prop}" + ) diff --git a/libnmstate/ifaces/vxlan.py b/libnmstate/ifaces/vxlan.py new file mode 100644 index 0000000..aa86dae --- /dev/null +++ b/libnmstate/ifaces/vxlan.py @@ -0,0 +1,58 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from libnmstate.error import NmstateValueError +from libnmstate.schema import VXLAN + +from .base_iface import BaseIface + + +class VxlanIface(BaseIface): + @property + def parent(self): + return self._vxlan_config.get(VXLAN.BASE_IFACE) + + @property + def need_parent(self): + return True + + @property + def _vxlan_config(self): + return self.raw.get(VXLAN.CONFIG_SUBTREE, {}) + + @property + def is_virtual(self): + return True + + @property + def can_have_ip_when_enslaved(self): + return True + + def pre_edit_validation_and_cleanup(self): + self._validate_mandatory_properties() + super().pre_edit_validation_and_cleanup() + + def _validate_mandatory_properties(self): + if self.is_up: + for prop in (VXLAN.ID, VXLAN.BASE_IFACE, VXLAN.REMOTE): + if prop not in self._vxlan_config: + raise NmstateValueError( + f"Vxlan tunnel {self.name} has missing mandatory " + f"property: {prop}" + ) diff --git a/libnmstate/iplib.py b/libnmstate/iplib.py new file mode 100644 index 0000000..183b81b --- /dev/null +++ b/libnmstate/iplib.py @@ -0,0 +1,68 @@ +# +# Copyright (c) 2018-2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import ipaddress +from libnmstate.error import NmstateValueError + +_IPV6_LINK_LOCAL_NETWORK_PREFIXES = ["fe8", "fe9", "fea", "feb"] +_IPV6_LINK_LOCAL_NETWORK_PREFIX_LENGTH = 10 + +KERNEL_MAIN_ROUTE_TABLE_ID = 254 + + +def is_ipv6_link_local_addr(ip, prefix): + return ( + ip[: len(_IPV6_LINK_LOCAL_NETWORK_PREFIXES[0])] + in _IPV6_LINK_LOCAL_NETWORK_PREFIXES + and prefix >= _IPV6_LINK_LOCAL_NETWORK_PREFIX_LENGTH + ) + + +def is_ipv6_address(addr): + return ":" in addr + + +def to_ip_address_full(ip, prefix=None): + if prefix: + return f"{ip}/{prefix}" + else: + return to_ip_address_full(*ip_address_full_to_tuple(ip)) + + +def ip_address_full_to_tuple(addr): + try: + net = ipaddress.ip_network(addr) + except (ipaddress.AddressValueError, ipaddress.NetmaskValueError) as err: + raise NmstateValueError(f"Invalid IP address, error: {err}") + + return f"{net.network_address}", net.prefixlen + + +def canonicalize_ip_network(address): + try: + return ipaddress.ip_network(address, strict=False).with_prefixlen + except ValueError as e: + raise NmstateValueError(f"Invalid IP network address: {e}") + + +def canonicalize_ip_address(address): + try: + return ipaddress.ip_address(address).compressed + except ValueError as e: + raise NmstateValueError(f"Invalid IP address: {e}") diff --git a/libnmstate/net_state.py b/libnmstate/net_state.py new file mode 100644 index 0000000..2d80b53 --- /dev/null +++ b/libnmstate/net_state.py @@ -0,0 +1,74 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import copy + +from libnmstate.schema import DNS +from libnmstate.schema import Interface +from libnmstate.schema import Route +from libnmstate.schema import RouteRule + +from .ifaces import Ifaces +from .dns import DnsState +from .route import RouteState +from .route_rule import RouteRuleState + + +class NetState: + def __init__(self, desire_state, current_state=None, save_to_disk=True): + if current_state is None: + current_state = {} + self._ifaces = Ifaces( + desire_state.get(Interface.KEY), + current_state.get(Interface.KEY), + save_to_disk, + ) + self._route = RouteState( + self._ifaces, + desire_state.get(Route.KEY), + current_state.get(Route.KEY), + ) + self._dns = DnsState( + desire_state.get(DNS.KEY), current_state.get(DNS.KEY), + ) + self._route_rule = RouteRuleState( + self._route, + desire_state.get(RouteRule.KEY), + current_state.get(RouteRule.KEY), + ) + self.desire_state = copy.deepcopy(desire_state) + self.current_state = copy.deepcopy(current_state) + if self.desire_state: + self._ifaces.gen_dns_metadata(self._dns, self._route) + self._ifaces.gen_route_metadata(self._route) + self._ifaces.gen_route_rule_metadata(self._route_rule, self._route) + + def verify(self, current_state): + self._ifaces.verify(current_state.get(Interface.KEY)) + self._dns.verify(current_state.get(DNS.KEY)) + self._route.verify(current_state.get(Route.KEY)) + self._route_rule.verify(current_state.get(RouteRule.KEY)) + + @property + def ifaces(self): + return self._ifaces + + @property + def dns(self): + return self._dns diff --git a/libnmstate/netapplier.py b/libnmstate/netapplier.py new file mode 100644 index 0000000..24df4d5 --- /dev/null +++ b/libnmstate/netapplier.py @@ -0,0 +1,120 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import copy +import time + + +from libnmstate import validator +from libnmstate.error import NmstateVerificationError + +from .nmstate import create_checkpoints +from .nmstate import destroy_checkpoints +from .nmstate import plugin_context +from .nmstate import plugins_capabilities +from .nmstate import rollback_checkpoints +from .nmstate import show_with_plugins +from .net_state import NetState + +MAINLOOP_TIMEOUT = 35 +VERIFY_RETRY_INTERNAL = 1 +VERIFY_RETRY_TIMEOUT = 5 + + +def apply( + desired_state, + *, + verify_change=True, + commit=True, + rollback_timeout=60, + save_to_disk=True, +): + """ + Apply the desired state + + :param verify_change: Check if the outcome state matches the desired state + and rollback if not. + :param commit: Commit the changes after verification if the state matches. + :param rollback_timeout: Revert the changes if they are not commited within + this timeout (specified in seconds). + :type verify_change: bool + :type commit: bool + :type rollback_timeout: int (seconds) + :returns: Checkpoint identifier + :rtype: str + """ + desired_state = copy.deepcopy(desired_state) + with plugin_context() as plugins: + validator.schema_validate(desired_state) + current_state = show_with_plugins(plugins, include_status_data=True) + validator.validate_capabilities( + desired_state, plugins_capabilities(plugins) + ) + net_state = NetState(desired_state, current_state, save_to_disk) + checkpoints = create_checkpoints(plugins, rollback_timeout) + _apply_ifaces_state(plugins, net_state, verify_change, save_to_disk) + if commit: + destroy_checkpoints(plugins, checkpoints) + else: + return checkpoints + + +def commit(*, checkpoint=None): + """ + Commit a checkpoint that was received from `apply()`. + + :param checkpoint: Checkpoint to commit. If not specified, a checkpoint + will be selected and committed. + :type checkpoint: str + """ + with plugin_context() as plugins: + destroy_checkpoints(plugins, checkpoint) + + +def rollback(*, checkpoint=None): + """ + Roll back a checkpoint that was received from `apply()`. + + :param checkpoint: Checkpoint to roll back. If not specified, a checkpoint + will be selected and rolled back. + :type checkpoint: str + """ + with plugin_context() as plugins: + rollback_checkpoints(plugins, checkpoint) + + +def _apply_ifaces_state(plugins, net_state, verify_change, save_to_disk): + for plugin in plugins: + plugin.apply_changes(net_state, save_to_disk) + verified = False + if verify_change: + for _ in range(VERIFY_RETRY_TIMEOUT): + try: + _verify_change(plugins, net_state) + verified = True + break + except NmstateVerificationError: + time.sleep(VERIFY_RETRY_INTERNAL) + if not verified: + _verify_change(plugins, net_state) + + +def _verify_change(plugins, net_state): + current_state = show_with_plugins(plugins) + net_state.verify(current_state) diff --git a/libnmstate/netinfo.py b/libnmstate/netinfo.py new file mode 100644 index 0000000..a2efc0b --- /dev/null +++ b/libnmstate/netinfo.py @@ -0,0 +1,35 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from .nmstate import show_with_plugins +from .nmstate import plugin_context + + +def show(*, include_status_data=False): + """ + Reports configuration and status data on the system. + Configuration data is the set of writable data which can change the system + state. + Status data is the additional data which is not configuration data, + including read-only and statistics information. + When include_status_data is set, both are reported, otherwise only the + configuration data is reported. + """ + with plugin_context() as plugins: + return show_with_plugins(plugins, include_status_data) diff --git a/libnmstate/nm/__init__.py b/libnmstate/nm/__init__.py new file mode 100644 index 0000000..46facab --- /dev/null +++ b/libnmstate/nm/__init__.py @@ -0,0 +1,51 @@ +# +# Copyright (c) 2018-2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from . import applier +from . import bond +from . import bridge +from . import checkpoint +from . import connection +from . import device +from . import dns +from . import ipv4 +from . import ipv6 +from . import ovs +from . import translator +from . import user +from . import vlan +from . import wired +from .plugin import NetworkManagerPlugin + + +applier +bond +bridge +checkpoint +connection +device +dns +ipv4 +ipv6 +ovs +translator +user +vlan +wired +NetworkManagerPlugin diff --git a/libnmstate/nm/active_connection.py b/libnmstate/nm/active_connection.py new file mode 100644 index 0000000..062c78a --- /dev/null +++ b/libnmstate/nm/active_connection.py @@ -0,0 +1,177 @@ +# +# Copyright (c) 2019-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import logging + +from libnmstate.error import NmstateLibnmError + +from .common import GLib +from .common import GObject +from .common import NM + + +NM_AC_STATE_CHANGED_SIGNAL = "state-changed" + + +class ActivationError(Exception): + pass + + +class ActiveConnection: + def __init__(self, context=None, nm_ac_con=None): + self._ctx = context + self._act_con = nm_ac_con + + nmdevs = None + if nm_ac_con: + nmdevs = nm_ac_con.get_devices() + self._nmdev = nmdevs[0] if nmdevs else None + + def import_by_device(self, nmdev=None): + assert self._act_con is None + + if nmdev: + self._nmdev = nmdev + if self._nmdev: + self._act_con = self._nmdev.get_active_connection() + + def deactivate(self): + """ + Deactivating the current active connection, + The profile itself is not removed. + + For software devices, deactivation removes the devices from the kernel. + """ + act_connection = self._nmdev.get_active_connection() + if ( + not act_connection + or act_connection.props.state + == NM.ActiveConnectionState.DEACTIVATED + ): + return + + if self._act_con != act_connection: + raise NmstateLibnmError( + "When deactivating active connection, the newly get " + f"NM.ActiveConnection {act_connection}" + f"is different from original request: {self._act_con}" + ) + + action = f"Deactivate profile: {self.devname}" + self._ctx.register_async(action) + handler_id = act_connection.connect( + NM_AC_STATE_CHANGED_SIGNAL, + self._wait_state_changed_callback, + action, + ) + if act_connection.props.state != NM.ActiveConnectionState.DEACTIVATING: + user_data = (handler_id, action) + self._ctx.client.deactivate_connection_async( + act_connection, + self._ctx.cancellable, + self._deactivate_connection_callback, + user_data, + ) + + def _wait_state_changed_callback(self, act_con, state, reason, action): + if self._ctx.is_cancelled(): + return + if act_con.props.state == NM.ActiveConnectionState.DEACTIVATED: + logging.debug( + "Connection deactivation succeeded on %s", self.devname, + ) + self._ctx.finish_async(action) + + def _deactivate_connection_callback(self, src_object, result, user_data): + handler_id, action = user_data + if self._ctx.is_cancelled(): + if self._act_con: + self._act_con.handler_disconnect(handler_id) + return + + try: + success = src_object.deactivate_connection_finish(result) + except GLib.Error as e: + if e.matches( + NM.ManagerError.quark(), NM.ManagerError.CONNECTIONNOTACTIVE + ): + success = True + logging.debug( + "Connection is not active on {}, no need to " + "deactivate".format(self.devname) + ) + else: + if self._act_con: + self._act_con.handler_disconnect(handler_id) + self._ctx.fail( + NmstateLibnmError(f"{action} failed: error={e}") + ) + return + except Exception as e: + if self._act_con: + self._act_con.handler_disconnect(handler_id) + self._ctx.fail( + NmstateLibnmError( + f"BUG: Unexpected error when activating {self.devname} " + f"error={e}" + ) + ) + return + + if not success: + if self._act_con: + self._act_con.handler_disconnect(handler_id) + self._ctx.fail( + NmstateLibnmError( + f"{action} failed: error='None returned from " + "deactivate_connection_finish()'" + ) + ) + + @property + def nm_active_connection(self): + return self._act_con + + @property + def devname(self): + if self._nmdev: + return self._nmdev.get_iface() + else: + return None + + @property + def nmdevice(self): + return self._nmdev + + @nmdevice.setter + def nmdevice(self, nmdev): + assert self._nmdev is None + self._nmdev = nmdev + + +def _is_device_master_type(nmdev): + if nmdev: + is_master_type = ( + GObject.type_is_a(nmdev, NM.DeviceBond) + or GObject.type_is_a(nmdev, NM.DeviceBridge) + or GObject.type_is_a(nmdev, NM.DeviceTeam) + or GObject.type_is_a(nmdev, NM.DeviceOvsBridge) + ) + return is_master_type + return False diff --git a/libnmstate/nm/applier.py b/libnmstate/nm/applier.py new file mode 100644 index 0000000..4e20af5 --- /dev/null +++ b/libnmstate/nm/applier.py @@ -0,0 +1,604 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import logging +import itertools + +from libnmstate.error import NmstateValueError +from libnmstate.schema import Interface +from libnmstate.schema import InterfaceState +from libnmstate.schema import InterfaceType +from libnmstate.schema import LinuxBridge as LB +from libnmstate.schema import OVSBridge as OvsB +from libnmstate.schema import OVSInterface +from libnmstate.schema import Team +from libnmstate.ifaces.bond import BondIface +from libnmstate.ifaces.bridge import BridgeIface + +from . import bond +from . import bridge +from . import connection +from . import device +from . import ipv4 +from . import ipv6 +from . import lldp +from . import ovs +from . import sriov +from . import team +from . import translator +from . import user +from . import vlan +from . import vxlan +from . import wired +from .common import NM +from .dns import get_dns_config_iface_names + + +MAXIMUM_INTERFACE_LENGTH = 15 + +MASTER_METADATA = "_master" +MASTER_TYPE_METADATA = "_master_type" +MASTER_IFACE_TYPES = ( + InterfaceType.OVS_BRIDGE, + bond.BOND_TYPE, + LB.TYPE, + Team.TYPE, +) + + +def apply_changes(context, net_state, save_to_disk): + con_profiles = [] + + _preapply_dns_fix(context, net_state) + + ifaces_desired_state = net_state.ifaces.state_to_edit + ifaces_desired_state.extend( + _create_proxy_ifaces_desired_state(ifaces_desired_state) + ) + + for iface_desired_state in filter( + lambda s: s.get(Interface.STATE) != InterfaceState.ABSENT, + ifaces_desired_state, + ): + ifname = iface_desired_state[Interface.NAME] + nmdev = context.get_nm_dev(ifname) + cur_con_profile = None + if nmdev: + cur_con_profile = connection.ConnectionProfile(context) + cur_con_profile.import_by_device(nmdev) + else: + # Profile for virtual interface will remove interface when down + # hence search on existing NM.RemoteConnections + con_profile = context.client.get_connection_by_id(ifname) + if con_profile and con_profile.get_interface_name() == ifname: + cur_con_profile = connection.ConnectionProfile( + context, profile=con_profile + ) + original_desired_iface_state = {} + if net_state.ifaces.get(ifname): + iface = net_state.ifaces[ifname] + if iface.is_desired: + original_desired_iface_state = iface.original_dict + if ( + set(original_desired_iface_state.keys()) + <= set([Interface.STATE, Interface.NAME, Interface.TYPE]) + and cur_con_profile + and cur_con_profile.profile + and not net_state.ifaces[ifname].is_changed + ): + # Don't create new profile if original desire does not ask + # anything besides state:up and not been marked as changed. + # We don't need to do this once we support querying on-disk + # configure + con_profiles.append(cur_con_profile) + continue + new_con_profile = _build_connection_profile( + context, + iface_desired_state, + cur_con_profile, + original_desired_iface_state, + ) + if not new_con_profile.devname: + set_conn = new_con_profile.profile.get_setting_connection() + set_conn.props.interface_name = iface_desired_state[Interface.NAME] + if cur_con_profile and cur_con_profile.profile: + cur_con_profile.update(new_con_profile, save_to_disk) + con_profiles.append(new_con_profile) + else: + # Missing connection, attempting to create a new one. + connection.delete_iface_inactive_connections(context, ifname) + new_con_profile.add(save_to_disk) + con_profiles.append(new_con_profile) + context.wait_all_finish() + + _set_ifaces_admin_state(context, ifaces_desired_state, con_profiles) + context.wait_all_finish() + + +def _set_ifaces_admin_state(context, ifaces_desired_state, con_profiles): + """ + Control interface admin state by activating, deactivating and deleting + devices connection profiles. + + The `absent` state results in deactivating the device and deleting + the connection profile. + + For new virtual devices, the `up` state is handled by activating the + new connection profile. For existing devices, the device is activated, + leaving it to choose the correct profile. + + In order to activate correctly the interfaces, the order is significant: + - Master-less master interfaces. + - New interfaces (virtual interfaces, but not OVS ones). + - Master interfaces. + - OVS ports. + - OVS internal. + - All the rest. + """ + con_profiles_by_devname = _index_profiles_by_devname(con_profiles) + new_ifaces = _get_new_ifaces(context, con_profiles) + new_ifaces_to_activate = set() + new_vlan_ifaces_to_activate = set() + new_vxlan_ifaces_to_activate = set() + new_ovs_interface_to_activate = set() + new_ovs_port_to_activate = set() + new_master_not_enslaved_ifaces = set() + master_ifaces_to_edit = set() + ifaces_to_edit = set() + devs_to_deactivate = {} + devs_to_delete_profile = {} + devs_to_delete = {} + devs_to_deactivate_beforehand = [] + profiles_to_delete = [] + devs_to_activate_beforehand = [] + + current_profiles = context.client.get_connections() + + for iface_desired_state in ifaces_desired_state: + ifname = iface_desired_state[Interface.NAME] + nmdev = context.get_nm_dev(ifname) + if not nmdev: + if ( + ifname in new_ifaces + and iface_desired_state[Interface.STATE] == InterfaceState.UP + ): + if _is_master_iface( + iface_desired_state + ) and not _is_slave_iface(iface_desired_state): + new_master_not_enslaved_ifaces.add(ifname) + elif ( + iface_desired_state[Interface.TYPE] + == InterfaceType.OVS_INTERFACE + ): + new_ovs_interface_to_activate.add(ifname) + elif ( + iface_desired_state[Interface.TYPE] + == InterfaceType.OVS_PORT + ): + new_ovs_port_to_activate.add(ifname) + elif iface_desired_state[Interface.TYPE] == InterfaceType.VLAN: + new_vlan_ifaces_to_activate.add(ifname) + elif ( + iface_desired_state[Interface.TYPE] == InterfaceType.VXLAN + ): + new_vxlan_ifaces_to_activate.add(ifname) + else: + new_ifaces_to_activate.add(ifname) + elif iface_desired_state[Interface.STATE] == InterfaceState.ABSENT: + # Delete the down profiles + iface_name = iface_desired_state[Interface.NAME] + for current_profile in current_profiles: + if current_profile.get_interface_name() == iface_name: + profile = connection.ConnectionProfile( + context, current_profile + ) + profiles_to_delete.append(profile) + + else: + if not nmdev.get_managed(): + nmdev.set_managed(True) + if iface_desired_state[Interface.STATE] == InterfaceState.DOWN: + devs_to_activate_beforehand.append(nmdev) + if iface_desired_state[Interface.STATE] == InterfaceState.UP: + if ( + iface_desired_state.get(Interface.TYPE) + == InterfaceType.BOND + ): + iface = BondIface(iface_desired_state) + if iface.is_bond_mode_changed: + # NetworkManager leaves leftover in sysfs for bond + # options when changing bond mode, bug: + # https://bugzilla.redhat.com/show_bug.cgi?id=1819137 + # Workaround: delete the bond interface from kernel and + # create again via full deactivation beforehand. + logging.debug( + f"Bond interface {ifname} is changing bond mode, " + "will do full deactivation before applying changes" + ) + devs_to_deactivate_beforehand.append(nmdev) + + if _is_master_iface(iface_desired_state): + master_ifaces_to_edit.add( + (nmdev, con_profiles_by_devname[ifname].profile) + ) + else: + ifaces_to_edit.add( + (nmdev, con_profiles_by_devname[ifname].profile) + ) + elif iface_desired_state[Interface.STATE] in ( + InterfaceState.DOWN, + InterfaceState.ABSENT, + ): + nmdevs = _get_affected_devices(context, iface_desired_state) + is_absent = ( + iface_desired_state[Interface.STATE] + == InterfaceState.ABSENT + ) + for affected_nmdev in nmdevs: + devs_to_deactivate[ + affected_nmdev.get_iface() + ] = affected_nmdev + if is_absent: + devs_to_delete_profile[ + affected_nmdev.get_iface() + ] = affected_nmdev + if ( + is_absent + and nmdev.is_software() + and nmdev.get_device_type() != NM.DeviceType.VETH + ): + devs_to_delete[nmdev.get_iface()] = nmdev + else: + raise NmstateValueError( + "Invalid state {} for interface {}".format( + iface_desired_state[Interface.STATE], + iface_desired_state[Interface.NAME], + ) + ) + + for nmdev in devs_to_activate_beforehand: + profile = connection.ConnectionProfile(context) + profile.con_id = nmdev.get_iface() + profile.activate() + context.wait_all_finish() + + for dev in devs_to_deactivate_beforehand: + device.deactivate(context, dev) + + # Do not remove devices that are marked for editing. + for dev, _ in itertools.chain(master_ifaces_to_edit, ifaces_to_edit): + devs_to_deactivate.pop(dev.get_iface(), None) + devs_to_delete_profile.pop(dev.get_iface(), None) + devs_to_delete.pop(dev.get_iface(), None) + + for profile in profiles_to_delete: + profile.delete() + context.wait_all_finish() + + for ifname in new_master_not_enslaved_ifaces: + device.activate(context, dev=None, connection_id=ifname) + context.wait_all_finish() + + for ifname in new_ifaces_to_activate: + device.activate(context, dev=None, connection_id=ifname) + context.wait_all_finish() + + for dev, con_profile in master_ifaces_to_edit: + device.modify(context, dev, con_profile) + context.wait_all_finish() + + for ifname in new_ovs_port_to_activate: + device.activate(context, dev=None, connection_id=ifname) + context.wait_all_finish() + + for ifname in new_ovs_interface_to_activate: + device.activate(context, dev=None, connection_id=ifname) + context.wait_all_finish() + + for dev, con_profile in ifaces_to_edit: + device.modify(context, dev, con_profile) + context.wait_all_finish() + + for ifname in new_vlan_ifaces_to_activate: + device.activate(context, dev=None, connection_id=ifname) + context.wait_all_finish() + + for ifname in new_vxlan_ifaces_to_activate: + device.activate(context, dev=None, connection_id=ifname) + context.wait_all_finish() + + for dev in devs_to_deactivate.values(): + device.deactivate(context, dev) + context.wait_all_finish() + + for dev in devs_to_delete_profile.values(): + device.delete(context, dev) + context.wait_all_finish() + + for dev in devs_to_delete.values(): + device.delete_device(context, dev) + context.wait_all_finish() + + +def _index_profiles_by_devname(con_profiles): + return {con_profile.devname: con_profile for con_profile in con_profiles} + + +def _get_new_ifaces(context, con_profiles): + ifaces_without_device = set() + for con_profile in con_profiles: + ifname = con_profile.devname + nmdev = context.get_nm_dev(ifname) + if not nmdev: + # When the profile id is different from the iface name, use the + # profile id. + if ifname != con_profile.con_id: + ifname = con_profile.con_id + ifaces_without_device.add(ifname) + return ifaces_without_device + + +def _is_master_iface(iface_state): + return iface_state[Interface.TYPE] in MASTER_IFACE_TYPES + + +def _is_slave_iface(iface_state): + return iface_state.get(MASTER_METADATA) + + +def _get_affected_devices(context, iface_state): + nmdev = context.get_nm_dev(iface_state[Interface.NAME]) + devs = [] + if nmdev: + devs += [nmdev] + iface_type = iface_state[Interface.TYPE] + if iface_type == InterfaceType.OVS_BRIDGE: + port_slaves = ovs.get_slaves(nmdev) + iface_slaves = [ + iface for port in port_slaves for iface in ovs.get_slaves(port) + ] + devs += port_slaves + iface_slaves + elif iface_type == LB.TYPE: + devs += bridge.get_slaves(nmdev) + elif iface_type == bond.BOND_TYPE: + devs += bond.get_slaves(nmdev) + + ovs_port_dev = ovs.get_port_by_slave(nmdev) + if ovs_port_dev: + devs.append(ovs_port_dev) + return devs + + +def _create_proxy_ifaces_desired_state(ifaces_desired_state): + """ + Prepare the state of the "proxy" interfaces. These are interfaces that + exist as NM entities/profiles, but are invisible to the API. + These proxy interfaces state is created as a side effect of other ifaces + definition. + Note: This function modifies the ifaces_desired_state content in addition + to returning a new set of states for the proxy interfaces. + + In OVS case, the port profile is the proxy, it is not part of the public + state of the system, but internal to the NM provider. + """ + new_ifaces_desired_state = [] + new_ifaces_names = set() + for iface_desired_state in ifaces_desired_state: + master_type = iface_desired_state.get(MASTER_TYPE_METADATA) + if master_type != InterfaceType.OVS_BRIDGE: + continue + port_opts_metadata = iface_desired_state.get( + BridgeIface.BRPORT_OPTIONS_METADATA + ) + if port_opts_metadata is None: + continue + port_iface_desired_state = _create_ovs_port_iface_desired_state( + iface_desired_state, port_opts_metadata + ) + port_iface_name = port_iface_desired_state[Interface.NAME] + if port_iface_name not in new_ifaces_names: + new_ifaces_names.add(port_iface_name) + new_ifaces_desired_state.append(port_iface_desired_state) + # The "visible" slave/interface needs to point to the port profile + iface_desired_state[MASTER_METADATA] = port_iface_desired_state[ + Interface.NAME + ] + iface_desired_state[MASTER_TYPE_METADATA] = InterfaceType.OVS_PORT + return new_ifaces_desired_state + + +def _create_ovs_port_iface_desired_state(iface_desired_state, port_options): + iface_name = iface_desired_state[Interface.NAME] + if _is_ovs_lag_port(port_options): + port_name = port_options[OvsB.Port.NAME] + else: + port_name = ovs.PORT_PROFILE_PREFIX + iface_name + return { + Interface.NAME: port_name, + Interface.TYPE: InterfaceType.OVS_PORT, + Interface.STATE: iface_desired_state[Interface.STATE], + OvsB.OPTIONS_SUBTREE: port_options, + MASTER_METADATA: iface_desired_state[MASTER_METADATA], + MASTER_TYPE_METADATA: iface_desired_state[MASTER_TYPE_METADATA], + } + + +def _is_ovs_lag_port(port_state): + return port_state.get(OvsB.Port.LINK_AGGREGATION_SUBTREE) is not None + + +def _build_connection_profile( + context, + iface_desired_state, + base_con_profile, + original_desired_iface_state, +): + iface_type = translator.Api2Nm.get_iface_type( + iface_desired_state[Interface.TYPE] + ) + + base_profile = base_con_profile.profile if base_con_profile else None + + settings = [ + ipv4.create_setting( + iface_desired_state.get(Interface.IPV4), base_profile + ), + ipv6.create_setting( + iface_desired_state.get(Interface.IPV6), base_profile + ), + ] + + con_setting = connection.ConnectionSetting() + iface_name = iface_desired_state[Interface.NAME] + if base_profile: + con_setting.import_by_profile(base_con_profile) + con_setting.set_profile_name(iface_name) + else: + con_setting.create( + con_name=iface_name, iface_name=iface_name, iface_type=iface_type, + ) + lldp.apply_lldp_setting(con_setting, iface_desired_state) + + master = iface_desired_state.get(MASTER_METADATA) + _translate_master_type(iface_desired_state) + master_type = iface_desired_state.get(MASTER_TYPE_METADATA) + con_setting.set_master(master, master_type) + settings.append(con_setting.setting) + + # Only apply wired/ethernet configuration based on original desire + # state rather than the merged one. + wired_setting = wired.create_setting( + original_desired_iface_state, base_profile + ) + if wired_setting: + settings.append(wired_setting) + + user_setting = user.create_setting(iface_desired_state, base_profile) + if user_setting: + settings.append(user_setting) + + bond_opts = translator.Api2Nm.get_bond_options(iface_desired_state) + if bond_opts: + settings.append(bond.create_setting(bond_opts, wired_setting)) + elif iface_type == bridge.BRIDGE_TYPE: + bridge_config = iface_desired_state.get(bridge.BRIDGE_TYPE, {}) + bridge_options = bridge_config.get(LB.OPTIONS_SUBTREE) + bridge_ports = bridge_config.get(LB.PORT_SUBTREE) + + if bridge_options or bridge_ports: + linux_bridge_setting = bridge.create_setting( + iface_desired_state, + base_profile, + original_desired_iface_state, + ) + settings.append(linux_bridge_setting) + elif iface_type == InterfaceType.OVS_BRIDGE: + ovs_bridge_state = iface_desired_state.get(OvsB.CONFIG_SUBTREE, {}) + ovs_bridge_options = ovs_bridge_state.get(OvsB.OPTIONS_SUBTREE) + if ovs_bridge_options: + settings.append(ovs.create_bridge_setting(ovs_bridge_options)) + elif iface_type == InterfaceType.OVS_PORT: + ovs_port_options = iface_desired_state.get(OvsB.OPTIONS_SUBTREE) + settings.append(ovs.create_port_setting(ovs_port_options)) + elif iface_type == InterfaceType.OVS_INTERFACE: + patch_state = iface_desired_state.get( + OVSInterface.PATCH_CONFIG_SUBTREE + ) + settings.extend(ovs.create_interface_setting(patch_state)) + + bridge_port_options = iface_desired_state.get( + BridgeIface.BRPORT_OPTIONS_METADATA + ) + if bridge_port_options and master_type == bridge.BRIDGE_TYPE: + settings.append( + bridge.create_port_setting(bridge_port_options, base_profile) + ) + + vlan_setting = vlan.create_setting(iface_desired_state, base_profile) + if vlan_setting: + settings.append(vlan_setting) + + vxlan_setting = vxlan.create_setting(iface_desired_state, base_profile) + if vxlan_setting: + settings.append(vxlan_setting) + + sriov_setting = sriov.create_setting( + context, iface_desired_state, base_con_profile + ) + if sriov_setting: + settings.append(sriov_setting) + + team_setting = team.create_setting(iface_desired_state, base_con_profile) + if team_setting: + settings.append(team_setting) + + new_profile = connection.ConnectionProfile(context) + new_profile.create(settings) + return new_profile + + +def _translate_master_type(iface_desired_state): + """ + Translates the master type metadata names to their equivalent + NM type names. + """ + master_type = iface_desired_state.get(MASTER_TYPE_METADATA) + if master_type == LB.TYPE: + iface_desired_state[MASTER_TYPE_METADATA] = bridge.BRIDGE_TYPE + + +def _preapply_dns_fix(context, net_state): + """ + * When DNS configuration does not changed and old interface hold DNS + configuration is not included in `ifaces_desired_state`, preserve + the old DNS configure by removing DNS metadata from + `ifaces_desired_state`. + * When DNS configuration changed, include old interface which is holding + DNS configuration, so it's DNS configure could be removed. + """ + cur_dns_iface_names = get_dns_config_iface_names( + ipv4.acs_and_ip_profiles(context.client), + ipv6.acs_and_ip_profiles(context.client), + ) + + # Whether to mark interface as changed which is used for holding old DNS + # configurations + remove_existing_dns_config = False + # Whether to preserve old DNS config by DNS metadata to be removed from + # desired state + preserve_old_dns_config = False + if net_state.dns.config == net_state.dns.current_config: + for cur_dns_iface_name in cur_dns_iface_names: + iface = net_state.ifaces[cur_dns_iface_name] + if iface.is_changed or iface.is_desired: + remove_existing_dns_config = True + if not remove_existing_dns_config: + preserve_old_dns_config = True + else: + remove_existing_dns_config = True + + if remove_existing_dns_config: + for cur_dns_iface_name in cur_dns_iface_names: + iface = net_state.ifaces[cur_dns_iface_name] + iface.mark_as_changed() + + if preserve_old_dns_config: + for iface in net_state.ifaces.values(): + if iface.is_changed or iface.is_desired: + iface.remove_dns_metadata() diff --git a/libnmstate/nm/bond.py b/libnmstate/nm/bond.py new file mode 100644 index 0000000..9ea3648 --- /dev/null +++ b/libnmstate/nm/bond.py @@ -0,0 +1,160 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import contextlib +import os +import glob +import re + +from libnmstate.error import NmstateValueError +from libnmstate.ifaces.bond import BondIface +from libnmstate.schema import Bond +from .common import NM + + +BOND_TYPE = "bond" + +SYSFS_EMPTY_VALUE = "" + +NM_SUPPORTED_BOND_OPTIONS = NM.SettingBond.get_valid_options( + NM.SettingBond.new() +) + +SYSFS_BOND_OPTION_FOLDER_FMT = "/sys/class/net/{ifname}/bonding" + + +def create_setting(options, wired_setting): + bond_setting = NM.SettingBond.new() + _fix_bond_option_arp_interval(options) + for option_name, option_value in options.items(): + if wired_setting and BondIface.is_mac_restricted_mode( + options.get(Bond.MODE), options + ): + # When in MAC restricted mode, MAC address should be unset. + wired_setting.props.cloned_mac_address = None + if option_value != SYSFS_EMPTY_VALUE: + success = bond_setting.add_option(option_name, str(option_value)) + if not success: + raise NmstateValueError( + "Invalid bond option: '{}'='{}'".format( + option_name, option_value + ) + ) + + return bond_setting + + +def is_bond_type_id(type_id): + return type_id == NM.DeviceType.BOND + + +def get_bond_info(nm_device): + slaves = get_slaves(nm_device) + options = _get_options(nm_device) + if slaves or options: + return {"slaves": slaves, "options": options} + else: + return {} + + +def _get_options(nm_device): + ifname = nm_device.get_iface() + bond_option_names_in_profile = get_bond_option_names_in_profile(nm_device) + if ( + "miimon" in bond_option_names_in_profile + or "arp_interval" in bond_option_names_in_profile + ): + bond_option_names_in_profile.add("arp_interval") + bond_option_names_in_profile.add("miimon") + + # Mode is required + sysfs_folder = SYSFS_BOND_OPTION_FOLDER_FMT.format(ifname=ifname) + mode = _read_sysfs_file(f"{sysfs_folder}/mode") + + bond_setting = NM.SettingBond.new() + bond_setting.add_option(Bond.MODE, mode) + + options = {Bond.MODE: mode} + for sysfs_file in glob.iglob(f"{sysfs_folder}/*"): + option = os.path.basename(sysfs_file) + if option in NM_SUPPORTED_BOND_OPTIONS: + value = _read_sysfs_file(sysfs_file) + # When default_value is None, it means this option is invalid + # under this bond mode + default_value = bond_setting.get_option_default(option) + if ( + (default_value and value != default_value) + # Always include bond options which are explicitly defined in + # on-disk profile. + or option in bond_option_names_in_profile + ): + if option == "arp_ip_target": + value = value.replace(" ", ",") + options[option] = value + # Workaround of https://bugzilla.redhat.com/show_bug.cgi?id=1806549 + if "miimon" not in options: + options["miimon"] = bond_setting.get_option_default("miimon") + return options + + +def _read_sysfs_file(file_path): + with open(file_path) as fd: + return _strip_sysfs_name_number_value(fd.read().rstrip("\n")) + + +def _strip_sysfs_name_number_value(value): + """ + In sysfs/kernel, the value of some are shown with both human friendly + string and integer. For example, bond mode in sysfs is shown as + 'balance-rr 0'. This function only return the human friendly string. + """ + return re.sub(" [0-9]$", "", value) + + +def get_slaves(nm_device): + return nm_device.get_slaves() + + +def get_bond_option_names_in_profile(nm_device): + ac = nm_device.get_active_connection() + with contextlib.suppress(AttributeError): + bond_setting = ac.get_connection().get_setting_bond() + return { + bond_setting.get_option(i)[1] + for i in range(0, bond_setting.get_num_options()) + } + return set() + + +def _fix_bond_option_arp_interval(bond_options): + """ + Due to bug https://bugzilla.redhat.com/show_bug.cgi?id=1806549 + NM 1.22.8 treat 'arp_interval 0' as arp_interval enabled(0 actual means + disabled), which then conflict with 'miimon'. + The workaround is remove 'arp_interval 0' when 'miimon' > 0. + """ + if "miimon" in bond_options and "arp_interval" in bond_options: + try: + miimon = int(bond_options["miimon"]) + arp_interval = int(bond_options["arp_interval"]) + except ValueError as e: + raise NmstateValueError(f"Invalid bond option: {e}") + if miimon > 0 and arp_interval == 0: + bond_options.pop("arp_interval") + bond_options.pop("arp_ip_target", None) diff --git a/libnmstate/nm/bridge.py b/libnmstate/nm/bridge.py new file mode 100644 index 0000000..b885f7a --- /dev/null +++ b/libnmstate/nm/bridge.py @@ -0,0 +1,311 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import glob +import os + +from libnmstate.nm import connection +from libnmstate.nm.bridge_port_vlan import PortVlanFilter +from libnmstate.schema import LinuxBridge as LB +from .common import NM + + +BRIDGE_TYPE = "bridge" + +BRIDGE_PORT_NMSTATE_TO_SYSFS = { + LB.Port.STP_HAIRPIN_MODE: "hairpin_mode", + LB.Port.STP_PATH_COST: "path_cost", + LB.Port.STP_PRIORITY: "priority", +} + +SYSFS_USER_HZ_KEYS = [ + "forward_delay", + "ageing_time", + "hello_time", + "max_age", +] + +OPT = LB.Options + +EXTRA_OPTIONS_MAP = { + OPT.HELLO_TIMER: "hello_timer", + OPT.GC_TIMER: "gc_timer", + OPT.MULTICAST_ROUTER: "multicast_router", + OPT.GROUP_ADDR: "group_addr", + OPT.HASH_MAX: "hash_max", + OPT.MULTICAST_LAST_MEMBER_COUNT: "multicast_last_member_count", + OPT.MULTICAST_LAST_MEMBER_INTERVAL: "multicast_last_member_interval", + OPT.MULTICAST_QUERIER: "multicast_querier", + OPT.MULTICAST_QUERIER_INTERVAL: "multicast_querier_interval", + OPT.MULTICAST_QUERY_USE_IFADDR: "multicast_query_use_ifaddr", + OPT.MULTICAST_QUERY_INTERVAL: "multicast_query_interval", + OPT.MULTICAST_QUERY_RESPONSE_INTERVAL: "multicast_query_response_interval", + OPT.MULTICAST_STARTUP_QUERY_COUNT: "multicast_startup_query_count", + OPT.MULTICAST_STARTUP_QUERY_INTERVAL: "multicast_startup_query_interval", +} + +BOOL_OPTIONS = (OPT.MULTICAST_QUERIER, OPT.MULTICAST_QUERY_USE_IFADDR) + +NM_BRIDGE_OPTIONS_MAP = { + OPT.GROUP_ADDR: "group_address", + OPT.HASH_MAX: "multicast_hash_max", + OPT.MULTICAST_LAST_MEMBER_COUNT: "multicast_last_member_count", + OPT.MULTICAST_LAST_MEMBER_INTERVAL: "multicast_last_member_interval", + OPT.MULTICAST_MEMBERSHIP_INTERVAL: "multicast_membership_interval", + OPT.MULTICAST_QUERIER: "multicast_querier", + OPT.MULTICAST_QUERIER_INTERVAL: "multicast_querier_interval", + OPT.MULTICAST_QUERY_USE_IFADDR: "multicast_query_use_ifaddr", + OPT.MULTICAST_QUERY_INTERVAL: "multicast_query_interval", + OPT.MULTICAST_QUERY_RESPONSE_INTERVAL: "multicast_query_response_interval", + OPT.MULTICAST_STARTUP_QUERY_COUNT: "multicast_startup_query_count", + OPT.MULTICAST_STARTUP_QUERY_INTERVAL: "multicast_startup_query_interval", +} + + +def create_setting( + bridge_state, base_con_profile, original_desired_iface_state +): + options = original_desired_iface_state.get(LB.CONFIG_SUBTREE, {}).get( + LB.OPTIONS_SUBTREE + ) + bridge_setting = _get_current_bridge_setting(base_con_profile) + if not bridge_setting: + bridge_setting = NM.SettingBridge.new() + + if options: + _set_bridge_properties(bridge_setting, options) + + bridge_setting.props.vlan_filtering = _is_vlan_filter_active(bridge_state) + + return bridge_setting + + +def _get_current_bridge_setting(base_con_profile): + bridge_setting = None + if base_con_profile: + bridge_setting = base_con_profile.get_setting_bridge() + if bridge_setting: + bridge_setting = bridge_setting.duplicate() + return bridge_setting + + +def _set_bridge_properties(bridge_setting, options): + for key, val in options.items(): + if key == LB.Options.MAC_AGEING_TIME: + bridge_setting.props.ageing_time = val + elif key == LB.Options.GROUP_FORWARD_MASK: + bridge_setting.props.group_forward_mask = val + elif key == LB.Options.MULTICAST_SNOOPING: + bridge_setting.props.multicast_snooping = val + elif key == LB.STP_SUBTREE: + _set_bridge_stp_properties(bridge_setting, val) + elif key in NM_BRIDGE_OPTIONS_MAP: + nm_prop_name = NM_BRIDGE_OPTIONS_MAP[key] + # NM is using the sysfs name + if key == LB.Options.GROUP_ADDR: + val = val.lower() + setattr(bridge_setting.props, nm_prop_name, val) + + +def _set_bridge_stp_properties(bridge_setting, bridge_stp): + bridge_setting.props.stp = bridge_stp[LB.STP.ENABLED] + if bridge_stp[LB.STP.ENABLED] is True: + for stp_key, stp_val in bridge_stp.items(): + if stp_key == LB.STP.PRIORITY: + bridge_setting.props.priority = stp_val + elif stp_key == LB.STP.FORWARD_DELAY: + bridge_setting.props.forward_delay = stp_val + elif stp_key == LB.STP.HELLO_TIME: + bridge_setting.props.hello_time = stp_val + elif stp_key == LB.STP.MAX_AGE: + bridge_setting.props.max_age = stp_val + + +def _is_vlan_filter_active(bridge_state): + return any( + port.get(LB.Port.VLAN_SUBTREE, {}) != {} + for port in bridge_state.get(LB.CONFIG_SUBTREE, {}).get( + LB.PORT_SUBTREE, [] + ) + ) + + +def create_port_setting(options, base_con_profile): + port_setting = None + if base_con_profile: + port_setting = base_con_profile.get_setting_bridge_port() + if port_setting: + port_setting = port_setting.duplicate() + + if not port_setting: + port_setting = NM.SettingBridgePort.new() + + for key, val in options.items(): + if key == LB.Port.STP_PRIORITY: + port_setting.props.priority = val + elif key == LB.Port.STP_HAIRPIN_MODE: + port_setting.props.hairpin_mode = val + elif key == LB.Port.STP_PATH_COST: + port_setting.props.path_cost = val + elif key == LB.Port.VLAN_SUBTREE: + port_setting.clear_vlans() + for vlan_config in _create_port_vlans_setting(val): + port_setting.add_vlan(vlan_config) + + return port_setting + + +def _create_port_vlans_setting(val): + trunk_tags = val.get(LB.Port.Vlan.TRUNK_TAGS) + tag = val.get(LB.Port.Vlan.TAG) + enable_native_vlan = val.get(LB.Port.Vlan.ENABLE_NATIVE) + port_vlan_config = PortVlanFilter() + port_vlan_config.create_configuration(trunk_tags, tag, enable_native_vlan) + return (vlan_config for vlan_config in port_vlan_config.to_nm()) + + +def get_info(context, nmdev): + """ + Provides the current active values for a device + """ + info = {} + if nmdev.get_device_type() != NM.DeviceType.BRIDGE: + return info + bridge_setting = _get_bridge_setting(context, nmdev) + if not bridge_setting: + return info + + port_profiles_by_name = _get_slave_profiles_by_name(nmdev) + port_names_sysfs = _get_slaves_names_from_sysfs(nmdev.get_iface()) + props = _get_sysfs_bridge_options(nmdev.get_iface()) + info[LB.CONFIG_SUBTREE] = { + LB.PORT_SUBTREE: _get_bridge_ports_info( + port_profiles_by_name, + port_names_sysfs, + vlan_filtering_enabled=bridge_setting.get_vlan_filtering(), + ), + LB.OPTIONS_SUBTREE: { + LB.Options.MAC_AGEING_TIME: props["ageing_time"], + LB.Options.GROUP_FORWARD_MASK: props["group_fwd_mask"], + LB.Options.MULTICAST_SNOOPING: props["multicast_snooping"] > 0, + LB.STP_SUBTREE: { + LB.STP.ENABLED: props["stp_state"] > 0, + LB.STP.PRIORITY: props["priority"], + LB.STP.FORWARD_DELAY: props["forward_delay"], + LB.STP.HELLO_TIME: props["hello_time"], + LB.STP.MAX_AGE: props["max_age"], + }, + }, + } + + for schema_name, sysfs_key_name in EXTRA_OPTIONS_MAP.items(): + value = props[sysfs_key_name] + if schema_name == LB.Options.GROUP_ADDR: + value = value.upper() + elif schema_name in BOOL_OPTIONS: + value = value > 0 + info[LB.CONFIG_SUBTREE][LB.OPTIONS_SUBTREE][schema_name] = value + return info + + +def get_slaves(nm_device): + return nm_device.get_slaves() + + +def _get_bridge_setting(context, nmdev): + bridge_setting = None + bridge_con_profile = connection.ConnectionProfile(context) + bridge_con_profile.import_by_device(nmdev) + if bridge_con_profile.profile: + bridge_setting = bridge_con_profile.profile.get_setting_bridge() + return bridge_setting + + +def _get_bridge_ports_info( + port_profiles_by_name, port_names_sysfs, vlan_filtering_enabled=False +): + ports_info_by_name = { + name: _get_bridge_port_info(name) for name in port_names_sysfs + } + + for name, p in port_profiles_by_name.items(): + port_info = ports_info_by_name.get(name, {}) + if port_info: + if vlan_filtering_enabled: + bridge_vlan_config = p.get_setting_bridge_port().props.vlans + port_vlan = PortVlanFilter() + port_vlan.import_from_bridge_settings(bridge_vlan_config) + port_info[LB.Port.VLAN_SUBTREE] = port_vlan.to_dict() + return list(ports_info_by_name.values()) + + +def _get_slave_profiles_by_name(master_device): + slaves_profiles_by_name = {} + for dev in master_device.get_slaves(): + active_con = connection.get_device_active_connection(dev) + if active_con: + slaves_profiles_by_name[ + dev.get_iface() + ] = active_con.props.connection + return slaves_profiles_by_name + + +def _get_bridge_port_info(port_name): + """Report port runtime information from sysfs.""" + port = {LB.Port.NAME: port_name} + for option, option_sysfs in BRIDGE_PORT_NMSTATE_TO_SYSFS.items(): + sysfs_path = f"/sys/class/net/{port_name}/brport/{option_sysfs}" + with open(sysfs_path) as f: + option_value = int(f.read()) + if option == LB.Port.STP_HAIRPIN_MODE: + option_value = bool(option_value) + port[option] = option_value + return port + + +def _get_slaves_names_from_sysfs(master): + """ + We need to use glob in order to get the slaves name due to bug in + NetworkManager. + Ref: https://bugzilla.redhat.com/show_bug.cgi?id=1809547 + """ + slaves = [] + for sysfs_slave in glob.iglob(f"/sys/class/net/{master}/lower_*"): + # The format is lower_, we need to remove the "lower_" prefix + prefix_length = len("lower_") + slaves.append(os.path.basename(sysfs_slave)[prefix_length:]) + return slaves + + +def _get_sysfs_bridge_options(iface_name): + user_hz = os.sysconf("SC_CLK_TCK") + options = {} + for sysfs_file_path in glob.iglob(f"/sys/class/net/{iface_name}/bridge/*"): + key = os.path.basename(sysfs_file_path) + try: + with open(sysfs_file_path) as fd: + value = fd.read().rstrip("\n") + options[key] = value + options[key] = int(value, base=0) + except Exception: + pass + for key, value in options.items(): + if key in SYSFS_USER_HZ_KEYS: + options[key] = int(value / user_hz) + return options diff --git a/libnmstate/nm/bridge_port_vlan.py b/libnmstate/nm/bridge_port_vlan.py new file mode 100644 index 0000000..2ca5d86 --- /dev/null +++ b/libnmstate/nm/bridge_port_vlan.py @@ -0,0 +1,208 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from libnmstate.schema import LinuxBridge as LB + +from .common import NM + + +class PortVlanFilter: + def __init__(self): + self._trunk_tags = [] + self._tag = None + self._is_native = None + self._port_mode = None + + def create_configuration(self, trunk_tags, tag, is_native_vlan=False): + """ + Fill the PortVlanFilter object with data whose format is tied to the + API. + :param trunk_tags: list of schema.LinuxBridge.Port.Vlan.TrunkTags + objects. + :param tag: the access tag for access ports, the native vlan ID for + trunk ports + :param is_native_vlan: boolean attribute indicating if the trunk port + has a native vlan. + """ + self._trunk_tags = trunk_tags + self._tag = tag + self._is_native = is_native_vlan + self._port_mode = ( + LB.Port.Vlan.Mode.TRUNK if trunk_tags else LB.Port.Vlan.Mode.ACCESS + ) + + @property + def trunk_tags(self): + return self._trunk_tags + + @property + def tag(self): + return self._tag + + @property + def is_native(self): + return self._is_native + + @property + def port_mode(self): + return self._port_mode + + def to_nm(self): + """ + Generate a list of NM.BridgeVlan objects from the encapsulated + PortVlanFilter data + """ + + port_vlan_config = [] + if self._port_mode == LB.Port.Vlan.Mode.TRUNK: + port_vlan_config += map( + PortVlanFilter._generate_vlan_trunk_port_config, + self._trunk_tags, + ) + if self._is_native and self._tag: + port_vlan_config.append( + PortVlanFilter._generate_vlan_access_port_config(self._tag) + ) + elif self._port_mode == LB.Port.Vlan.Mode.ACCESS and self._tag: + port_vlan_config.append( + PortVlanFilter._generate_vlan_access_port_config(self._tag) + ) + + return port_vlan_config + + def to_dict(self): + """ + Get the port vlan filtering configuration in dict format - e.g. in yaml + format: + - name: eth1 + vlan: + type: trunk + trunk-tags: + - id: 101 + - id-range: + min: 200 + max: 4095 + tag: 100 + enable-native: true + """ + + port_vlan_state = { + LB.Port.Vlan.MODE: self._port_mode, + LB.Port.Vlan.TRUNK_TAGS: self._trunk_tags, + } + if self._tag: + port_vlan_state[LB.Port.Vlan.TAG] = self._tag + if self._port_mode == LB.Port.Vlan.Mode.TRUNK: + port_vlan_state[LB.Port.Vlan.ENABLE_NATIVE] = self._is_native + return port_vlan_state + + def import_from_bridge_settings(self, nm_bridge_vlans): + """ + Instantiates a PortVlanFilter object from a list of NM.BridgeVlan + objects. + """ + + self._is_native = False + trunk_tags = [] + + is_access_port = PortVlanFilter._is_access_port(nm_bridge_vlans) + for nm_bridge_vlan in nm_bridge_vlans: + vlan_min, vlan_max = PortVlanFilter.get_vlan_tag_range( + nm_bridge_vlan + ) + if is_access_port: + self._tag = vlan_min + elif nm_bridge_vlan.is_pvid() and nm_bridge_vlan.is_untagged(): + # an NM.BridgeVlan has a range and can be PVID and/or untagged + # according to NM's model, PVID / untagged apply to the 'max' + # part of the range + self._tag = vlan_max + self._is_native = True + else: + trunk_tags.append( + PortVlanFilter._translate_nm_bridge_vlan_to_trunk_tags( + vlan_min, vlan_max + ) + ) + + self._trunk_tags = trunk_tags + self._port_mode = ( + LB.Port.Vlan.Mode.TRUNK if trunk_tags else LB.Port.Vlan.Mode.ACCESS + ) + + @staticmethod + def get_vlan_tag_range(nm_bridge_vlan): + """ + Extract the vlan tags from the NM.BridgeVlan object. + A single NM.BridgeVlan object can have a range of vlan tags, or a + single one. + When a NM.BridgeVlan holds a single tag, the min_range and max_range + returned will have the same vlan tag. + :return: min_range, max_range + """ + + port_vlan_tags = nm_bridge_vlan.to_str().split() + + if "-" in port_vlan_tags[0]: + vlan_min, vlan_max = port_vlan_tags[0].split("-") + return int(vlan_min), int(vlan_max) + else: + tag = int(port_vlan_tags[0]) + return tag, tag + + @staticmethod + def _is_access_port(nm_bridge_vlan_ports): + return ( + len(nm_bridge_vlan_ports) == 1 + and nm_bridge_vlan_ports[0].is_pvid() + and nm_bridge_vlan_ports[0].is_untagged() + ) + + @staticmethod + def _translate_nm_bridge_vlan_to_trunk_tags(min_vlan, max_vlan): + if max_vlan != min_vlan: + port_data = { + LB.Port.Vlan.TrunkTags.ID_RANGE: { + LB.Port.Vlan.TrunkTags.MIN_RANGE: min_vlan, + LB.Port.Vlan.TrunkTags.MAX_RANGE: max_vlan, + } + } + else: + port_data = {LB.Port.Vlan.TrunkTags.ID: min_vlan} + + return port_data + + @staticmethod + def _generate_vlan_trunk_port_config(trunk_port): + min_range = max_range = trunk_port.get(LB.Port.Vlan.TrunkTags.ID) + if min_range is None: + ranged_vlan_tags = trunk_port.get(LB.Port.Vlan.TrunkTags.ID_RANGE) + min_range = ranged_vlan_tags[LB.Port.Vlan.TrunkTags.MIN_RANGE] + max_range = ranged_vlan_tags[LB.Port.Vlan.TrunkTags.MAX_RANGE] + port_vlan = NM.BridgeVlan.new(min_range, max_range) + port_vlan.set_untagged(False) + port_vlan.set_pvid(False) + return port_vlan + + @staticmethod + def _generate_vlan_access_port_config(vlan_tag): + port_vlan = NM.BridgeVlan.new(vlan_tag, vlan_tag) + port_vlan.set_untagged(True) + port_vlan.set_pvid(True) + return port_vlan diff --git a/libnmstate/nm/checkpoint.py b/libnmstate/nm/checkpoint.py new file mode 100644 index 0000000..18e1c3a --- /dev/null +++ b/libnmstate/nm/checkpoint.py @@ -0,0 +1,226 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import logging + +from libnmstate.error import NmstateConflictError +from libnmstate.error import NmstateLibnmError +from libnmstate.error import NmstatePermissionError +from libnmstate.nm import connection +from libnmstate.nm import common +from .connection import is_activated + + +def get_checkpoints(nm_client): + checkpoints = [c.get_path() for c in nm_client.get_checkpoints()] + return checkpoints + + +class CheckPoint: + def __init__(self, nm_context, timeout=60, dbuspath=None): + self._ctx = nm_context + self._timeout = timeout + self._dbuspath = dbuspath + self._timeout_source = None + + def __str__(self): + return self._dbuspath + + @staticmethod + def create(nm_context, timeout=60): + cp = CheckPoint(nm_context=nm_context, timeout=timeout) + cp._create() + return cp + + def _create(self): + devs = [] + timeout = self._timeout + cp_flags = ( + common.NM.CheckpointCreateFlags.DELETE_NEW_CONNECTIONS + | common.NM.CheckpointCreateFlags.DISCONNECT_NEW_DEVICES + ) + + self._ctx.register_async("Create checkpoint") + self._ctx.client.checkpoint_create( + devs, + timeout, + cp_flags, + self._ctx.cancellable, + self._checkpoint_create_callback, + None, + ) + self._ctx.wait_all_finish() + self._add_checkpoint_refresh_timeout() + + def _add_checkpoint_refresh_timeout(self): + self._timeout_source = common.GLib.timeout_source_new( + self._timeout * 500 + ) + self._timeout_source.set_callback( + self._refresh_checkpoint_timeout, None + ) + self._timeout_source.attach(self._ctx.context) + + def clean_up(self): + self._remove_checkpoint_refresh_timeout() + + def _remove_checkpoint_refresh_timeout(self): + if self._timeout_source: + self._timeout_source.destroy() + self._timeout_source = None + + def _refresh_checkpoint_timeout(self, _user_data): + cancellable, cb, cb_data = (None, None, None) + + if self._ctx and self._ctx.client: + self._ctx.client.checkpoint_adjust_rollback_timeout( + self._dbuspath, self._timeout, cancellable, cb, cb_data + ) + return common.GLib.SOURCE_CONTINUE + else: + return common.GLib.SOURCE_REMOVE + + def destroy(self): + if self._dbuspath: + action = f"Destroy checkpoint {self._dbuspath}" + userdata = action + self._ctx.register_async(action) + self._ctx.client.checkpoint_destroy( + self._dbuspath, + self._ctx.cancellable, + self._checkpoint_destroy_callback, + userdata, + ) + self._ctx.wait_all_finish() + self.clean_up() + + def rollback(self): + if self._dbuspath: + action = f"Rollback to checkpoint {self._dbuspath}" + self._ctx.register_async(action) + userdata = action + self._ctx.client.checkpoint_rollback( + self._dbuspath, + self._ctx.cancellable, + self._checkpoint_rollback_callback, + userdata, + ) + self._ctx.wait_all_finish() + self.clean_up() + + def _checkpoint_create_callback(self, client, result, data): + try: + cp = client.checkpoint_create_finish(result) + if cp: + logging.debug( + "Checkpoint {} created for all devices".format( + self._dbuspath + ) + ) + self._dbuspath = cp.get_path() + self._ctx.finish_async("Create checkpoint") + else: + error_msg = ( + f"dbuspath={self._dbuspath} " + f"timeout={self._timeout} " + f"callback result={cp}" + ) + self._ctx.fail( + NmstateLibnmError(f"Checkpoint create failed: {error_msg}") + ) + except common.GLib.Error as e: + if e.matches( + common.NM.ManagerError.quark(), + common.NM.ManagerError.PERMISSIONDENIED, + ): + self._ctx.fail( + NmstatePermissionError( + "Checkpoint create failed due to insufficient" + " permission" + ) + ) + elif e.matches( + common.NM.ManagerError.quark(), + common.NM.ManagerError.INVALIDARGUMENTS, + ): + self._ctx.fail( + NmstateConflictError( + "Checkpoint create failed due to a" + " conflict with an existing checkpoint" + ) + ) + else: + self._ctx.fail( + NmstateLibnmError(f"Checkpoint create failed: error={e}") + ) + except Exception as e: + self._ctx.fail( + NmstateLibnmError(f"Checkpoint create failed: error={e}") + ) + + def _checkpoint_rollback_callback(self, client, result, data): + action = data + try: + self._check_rollback_result(client, result, self._dbuspath) + self._dbuspath = None + self._ctx.finish_async(action) + except Exception as e: + self._ctx.fail( + NmstateLibnmError(f"Checkpoint rollback failed: error={e}") + ) + + def _check_rollback_result(self, client, result, dbus_path): + ret = client.checkpoint_rollback_finish(result) + logging.debug(f"Checkpoint {dbus_path} rollback executed") + for path in ret: + nm_dev = client.get_device_by_path(path) + iface = path if nm_dev is None else nm_dev.get_iface() + if nm_dev and ( + ( + nm_dev.get_state_reason() + == common.NM.DeviceStateReason.NEW_ACTIVATION + ) + or nm_dev.get_state() == common.NM.DeviceState.IP_CONFIG + ): + nm_ac = nm_dev.get_active_connection() + if not is_activated(nm_ac, nm_dev): + profile = connection.ConnectionProfile(self._ctx) + profile.nmdevice = nm_dev + action = f"Waiting for rolling back {iface}" + self._ctx.register_async(action) + profile.wait_dev_activation(action) + if ret[path] != 0: + logging.error(f"Interface {iface} rollback failed") + else: + logging.debug(f"Interface {iface} rollback succeeded") + + def _checkpoint_destroy_callback(self, client, result, data): + action = data + try: + client.checkpoint_destroy_finish(result) + logging.debug(f"Checkpoint {self._dbuspath} destroyed") + self._dbuspath = None + self._ctx.finish_async(action) + except Exception as e: + self._ctx.fail( + NmstateLibnmError( + f"Checkpoint {self._dbuspath} destroy failed: " + f"error={e}" + ) + ) diff --git a/libnmstate/nm/common.py b/libnmstate/nm/common.py new file mode 100644 index 0000000..b63ec93 --- /dev/null +++ b/libnmstate/nm/common.py @@ -0,0 +1,37 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import gi + +try: + gi.require_version("NM", "1.0") # NOQA: F402 + from gi.repository import NM # pylint: disable=no-name-in-module +except ValueError: + NM = None + +from gi.repository import GLib +from gi.repository import GObject +from gi.repository import Gio + + +# To suppress the "import not used" error +NM +GLib +GObject +Gio diff --git a/libnmstate/nm/connection.py b/libnmstate/nm/connection.py new file mode 100644 index 0000000..02890bc --- /dev/null +++ b/libnmstate/nm/connection.py @@ -0,0 +1,542 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import logging +import uuid + +from libnmstate.error import NmstateLibnmError +from libnmstate.error import NmstateInternalError +from libnmstate.error import NmstateValueError + +from .common import NM +from . import ipv4 +from . import ipv6 + +ACTIVATION_TIMEOUT_FOR_BRIDGE = 35 # Bridge STP requires 30 seconds. + + +class ConnectionProfile: + def __init__(self, context, profile=None): + self._ctx = context + self._con_profile = profile + self._nm_dev = None + self._con_id = None + self._nm_ac = None + self._ac_handlers = set() + self._dev_handlers = set() + + def create(self, settings): + self.profile = NM.SimpleConnection.new() + for setting in settings: + self.profile.add_setting(setting) + + def import_by_device(self, nmdev=None): + ac = get_device_active_connection(nmdev or self.nmdevice) + if ac: + if nmdev: + self.nmdevice = nmdev + self.profile = ac.props.connection + + def import_by_id(self, con_id=None): + if con_id: + self.con_id = con_id + if self.con_id: + self.profile = self._ctx.client.get_connection_by_id(self.con_id) + + def update(self, con_profile, save_to_disk=True): + flags = NM.SettingsUpdate2Flags.BLOCK_AUTOCONNECT + if save_to_disk: + flags |= NM.SettingsUpdate2Flags.TO_DISK + else: + flags |= NM.SettingsUpdate2Flags.IN_MEMORY + action = f"Update profile: {self.profile.get_id()}" + user_data = action + args = None + + self._ctx.register_async(action, fast=True) + self.profile.update2( + con_profile.profile.to_dbus(NM.ConnectionSerializationFlags.ALL), + flags, + args, + self._ctx.cancellable, + self._update2_callback, + user_data, + ) + + def add(self, save_to_disk=True): + nm_add_conn2_flags = NM.SettingsAddConnection2Flags + flags = nm_add_conn2_flags.BLOCK_AUTOCONNECT + if save_to_disk: + flags |= nm_add_conn2_flags.TO_DISK + else: + flags |= nm_add_conn2_flags.IN_MEMORY + + action = f"Add profile: {self.profile.get_id()}" + self._ctx.register_async(action, fast=True) + + user_data = action + args = None + ignore_out_result = False # Don't fall back to old AddConnection() + self._ctx.client.add_connection2( + self.profile.to_dbus(NM.ConnectionSerializationFlags.ALL), + flags, + args, + ignore_out_result, + self._ctx.cancellable, + self._add_connection2_callback, + user_data, + ) + + def delete(self): + if not self.profile: + self.import_by_id() + if not self.profile: + self.import_by_device() + if self.profile: + action = ( + f"Delete profile: id:{self.profile.get_id()}, " + f"uuid:{self.profile.get_uuid()}" + ) + user_data = action + self._ctx.register_async(action, fast=True) + self.profile.delete_async( + self._ctx.cancellable, + self._delete_connection_callback, + user_data, + ) + + def activate(self): + if self.con_id: + self.import_by_id() + elif self.nmdevice: + self.import_by_device() + elif not self.profile: + raise NmstateInternalError( + "BUG: Failed to find valid profile to activate: " + f"id={self.con_id}, dev={self.devname}" + ) + + specific_object = None + if self.profile: + action = f"Activate profile: {self.profile.get_id()}" + elif self.nmdevice: + action = f"Activate profile: {self.nmdevice.get_iface()}" + else: + raise NmstateInternalError( + "BUG: Cannot activate a profile with empty profile id and " + "empty NM.Device" + ) + user_data = action + self._ctx.register_async(action) + self._ctx.client.activate_connection_async( + self.profile, + self.nmdevice, + specific_object, + self._ctx.cancellable, + self._active_connection_callback, + user_data, + ) + + @property + def profile(self): + return self._con_profile + + @profile.setter + def profile(self, con_profile): + assert self._con_profile is None + self._con_profile = con_profile + + @property + def devname(self): + if self._con_profile: + return self._con_profile.get_interface_name() + return None + + @property + def nmdevice(self): + return self._nm_dev + + @nmdevice.setter + def nmdevice(self, dev): + assert self._nm_dev is None + self._nm_dev = dev + + @property + def con_id(self): + con_id = self._con_profile.get_id() if self._con_profile else None + return self._con_id or con_id + + @con_id.setter + def con_id(self, connection_id): + assert self._con_id is None + self._con_id = connection_id + + def get_setting_duplicate(self, setting_name): + setting = None + if self.profile: + setting = self.profile.get_setting_by_name(setting_name) + if setting: + setting = setting.duplicate() + return setting + + def _active_connection_callback(self, src_object, result, user_data): + if self._ctx.is_cancelled(): + self._activation_clean_up() + return + action = user_data + + try: + nm_act_con = src_object.activate_connection_finish(result) + except Exception as e: + self._ctx.fail(NmstateLibnmError(f"{action} failed: error={e}")) + return + + if nm_act_con is None: + self._ctx.fail( + NmstateLibnmError( + f"{action} failed: " + "error='None return from activate_connection_finish()'" + ) + ) + else: + devname = self.devname + logging.debug( + "Connection activation initiated: dev=%s, con-state=%s", + devname, + nm_act_con.props.state, + ) + self._nm_ac = nm_act_con + self._nm_dev = self._ctx.get_nm_dev(devname) + + if is_activated(self._nm_ac, self._nm_dev): + self._ctx.finish_async(action) + elif self._is_activating(): + self._wait_ac_activation(action) + if self._nm_dev: + self.wait_dev_activation(action) + else: + if self._nm_dev: + error_msg = ( + f"Connection {self.profile.get_id()} failed: " + f"state={self._nm_ac.get_state()} " + f"reason={self._nm_ac.get_state_reason()} " + f"dev_state={self._nm_dev.get_state()} " + f"dev_reason={self._nm_dev.get_state_reason()}" + ) + else: + error_msg = ( + f"Connection {self.profile.get_id()} failed: " + f"state={self._nm_ac.get_state()} " + f"reason={self._nm_ac.get_state_reason()} dev=None" + ) + logging.error(error_msg) + self._ctx.fail( + NmstateLibnmError(f"{action} failed: {error_msg}") + ) + + def _wait_ac_activation(self, action): + self._ac_handlers.add( + self._nm_ac.connect( + "state-changed", self._ac_state_change_callback, action + ) + ) + self._ac_handlers.add( + self._nm_ac.connect( + "notify::state-flags", + self._ac_state_flags_change_callback, + action, + ) + ) + + def wait_dev_activation(self, action): + if self._nm_dev: + self._dev_handlers.add( + self._nm_dev.connect( + "state-changed", self._dev_state_change_callback, action + ) + ) + + def _dev_state_change_callback( + self, _dev, _new_state, _old_state, _reason, action, + ): + if self._ctx.is_cancelled(): + self._activation_clean_up() + return + self._activation_progress_check(action) + + def _ac_state_flags_change_callback(self, _nm_act_con, _state, action): + if self._ctx.is_cancelled(): + self._activation_clean_up() + return + self._activation_progress_check(action) + + def _ac_state_change_callback(self, _nm_act_con, _state, _reason, action): + if self._ctx.is_cancelled(): + self._activation_clean_up() + return + self._activation_progress_check(action) + + def _activation_progress_check(self, action): + if self._ctx.is_cancelled(): + self._activation_clean_up() + return + devname = self._nm_dev.get_iface() + cur_nm_dev = self._ctx.get_nm_dev(devname) + if cur_nm_dev and cur_nm_dev != self._nm_dev: + logging.debug(f"The NM.Device of profile {devname} changed") + self._remove_dev_handlers() + self._nm_dev = cur_nm_dev + self.wait_dev_activation(action) + + cur_nm_ac = get_device_active_connection(self.nmdevice) + if cur_nm_ac and cur_nm_ac != self._nm_ac: + logging.debug( + "Active connection of device {} has been replaced".format( + self.devname + ) + ) + self._remove_ac_handlers() + self._nm_ac = cur_nm_ac + self._wait_ac_activation(action) + if is_activated(self._nm_ac, self._nm_dev): + logging.debug( + "Connection activation succeeded: dev=%s, con-state=%s, " + "dev-state=%s, state-flags=%s", + devname, + self._nm_ac.get_state(), + self._nm_dev.get_state(), + self._nm_ac.get_state_flags(), + ) + self._activation_clean_up() + self._ctx.finish_async(action) + elif ( + not self._is_activating() + and self._is_sriov_parameter_not_supported_by_driver() + ): + reason = ( + f"The device={self.devname} does not support one or " + "more of the SR-IOV parameters set." + ) + self._activation_clean_up() + self._ctx.fail( + NmstateValueError(f"{action} failed: reason={reason}") + ) + elif not self._is_activating(): + reason = f"{self._nm_ac.get_state_reason()}" + if self.nmdevice: + reason += f" {self.nmdevice.get_state_reason()}" + self._activation_clean_up() + self._ctx.fail( + NmstateLibnmError(f"{action} failed: reason={reason}") + ) + + def _is_sriov_parameter_not_supported_by_driver(self): + return ( + self.nmdevice + and self.nmdevice.props.state_reason + == NM.DeviceStateReason.SRIOV_CONFIGURATION_FAILED + ) + + def _activation_clean_up(self): + self._remove_ac_handlers() + self._remove_dev_handlers() + + def _is_activating(self): + if not self._nm_ac or not self._nm_dev: + return True + if ( + self._nm_dev.get_state_reason() + == NM.DeviceStateReason.NEW_ACTIVATION + ): + return True + + return ( + self._nm_ac.get_state() == NM.ActiveConnectionState.ACTIVATING + ) and not is_activated(self._nm_ac, self._nm_dev) + + def _remove_dev_handlers(self): + for handler_id in self._dev_handlers: + self._nm_dev.handler_disconnect(handler_id) + self._dev_handlers = set() + + def _remove_ac_handlers(self): + for handler_id in self._ac_handlers: + self._nm_ac.handler_disconnect(handler_id) + self._ac_handlers = set() + + def _add_connection2_callback(self, src_object, result, user_data): + if self._ctx.is_cancelled(): + return + action = user_data + try: + profile = src_object.add_connection2_finish(result)[0] + except Exception as e: + self._ctx.fail( + NmstateLibnmError(f"{action} failed with error: {e}") + ) + return + + if profile is None: + self._ctx.fail( + NmstateLibnmError( + f"{action} failed with error: 'None returned from " + "add_connection2_finish()" + ) + ) + else: + self._ctx.finish_async(action) + + def _update2_callback(self, src_object, result, user_data): + if self._ctx.is_cancelled(): + return + action = user_data + try: + ret = src_object.update2_finish(result) + except Exception as e: + self._ctx.fail( + NmstateLibnmError(f"{action} failed with error={e}") + ) + return + if ret is None: + self._ctx.fail( + NmstateLibnmError( + f"{action} failed with error='None returned from " + "update2_finish()'" + ) + ) + else: + self._ctx.finish_async(action) + + def _delete_connection_callback(self, src_object, result, user_data): + if self._ctx.is_cancelled(): + return + action = user_data + try: + success = src_object.delete_finish(result) + except Exception as e: + self._ctx.fail(NmstateLibnmError(f"{action} failed: error={e}")) + return + + if success: + self._ctx.finish_async(action) + else: + self._ctx.fail( + NmstateLibnmError( + f"{action} failed: " + "error='None returned from delete_finish()'" + ) + ) + + def _reset_profile(self): + self._con_profile = None + + +class ConnectionSetting: + def __init__(self, con_setting=None): + self._setting = con_setting + + def create(self, con_name, iface_name, iface_type): + con_setting = NM.SettingConnection.new() + con_setting.props.id = con_name + con_setting.props.interface_name = iface_name + con_setting.props.uuid = str(uuid.uuid4()) + con_setting.props.type = iface_type + con_setting.props.autoconnect = True + con_setting.props.autoconnect_slaves = ( + NM.SettingConnectionAutoconnectSlaves.YES + ) + + self._setting = con_setting + + def import_by_profile(self, con_profile): + base = con_profile.profile.get_setting_connection() + new = NM.SettingConnection.new() + new.props.id = base.props.id + new.props.interface_name = base.props.interface_name + new.props.uuid = base.props.uuid + new.props.type = base.props.type + new.props.autoconnect = True + new.props.autoconnect_slaves = base.props.autoconnect_slaves + + self._setting = new + + def set_master(self, master, slave_type): + if master is not None: + self._setting.props.master = master + self._setting.props.slave_type = slave_type + + def set_profile_name(self, con_name): + self._setting.props.id = con_name + + @property + def setting(self): + return self._setting + + +def get_device_active_connection(nm_device): + active_conn = None + if nm_device: + active_conn = nm_device.get_active_connection() + return active_conn + + +def delete_iface_inactive_connections(context, ifname): + for con in list_connections_by_ifname(context, ifname): + con.delete() + + +def list_connections_by_ifname(context, ifname): + return [ + ConnectionProfile(context, profile=con) + for con in context.client.get_connections() + if con.get_interface_name() == ifname + ] + + +def is_activated(nm_ac, nm_dev): + if not (nm_ac and nm_dev): + return False + + state = nm_ac.get_state() + if state == NM.ActiveConnectionState.ACTIVATED: + return True + elif state == NM.ActiveConnectionState.ACTIVATING: + ac_state_flags = nm_ac.get_state_flags() + nm_flags = NM.ActivationStateFlags + ip4_is_dynamic = ipv4.is_dynamic(nm_ac) + ip6_is_dynamic = ipv6.is_dynamic(nm_ac) + if ( + ac_state_flags & nm_flags.IS_MASTER + or (ip4_is_dynamic and ac_state_flags & nm_flags.IP6_READY) + or (ip6_is_dynamic and ac_state_flags & nm_flags.IP4_READY) + or (ip4_is_dynamic and ip6_is_dynamic) + ): + # For interface meet any condition below will be + # treated as activated when reach IP_CONFIG state: + # * Is master device. + # * DHCPv4 enabled with IP6_READY flag. + # * DHCPv6/Autoconf with IP4_READY flag. + # * DHCPv4 enabled with DHCPv6/Autoconf enabled. + return ( + NM.DeviceState.IP_CONFIG + <= nm_dev.get_state() + <= NM.DeviceState.ACTIVATED + ) + + return False diff --git a/libnmstate/nm/context.py b/libnmstate/nm/context.py new file mode 100644 index 0000000..373ffe8 --- /dev/null +++ b/libnmstate/nm/context.py @@ -0,0 +1,220 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import datetime +import logging + +from libnmstate.error import NmstateInternalError +from libnmstate.error import NmstateTimeoutError + +from .common import NM +from .common import GLib +from .common import Gio + +# Interval for idle checker to check on whether timeout should trigger since +# last finish async action. +IDLE_CHECK_INTERNAL = 5 + +# libnm dbus connection has reply timeout 25 seconds. +IDLE_TIMEOUT = 25 + +# NetworkManage is using dbus in libnm while the dbus has limitation on +# maximum number of pending replies per connection.(RHEL/CentOS 8 is 1024) +# Hence limit the synchronous queue size +SLOW_ASYNC_QUEUE_SIZE = 100 +FAST_ASYNC_QUEUE_SIZE = 300 + + +class NmContext: + def __init__(self): + self._client = NM.Client.new(cancellable=None) + self._context = self._client.get_main_context() + self._quitting = False + self._cancellable = None + self._error = None + self._timeout_source = None + self._last_async_finish_time = None + self._fast_queue = None + self._slow_queue = None + self._init_queue() + self._init_cancellable() + + def _init_queue(self): + self._fast_queue = set() + self._slow_queue = set() + + def _init_cancellable(self): + self._cancellable = Gio.Cancellable.new() + + @property + def cancellable(self): + return self._cancellable + + @property + def client(self): + if self._quitting: + return None + return self._client + + @property + def context(self): + if not self._context: + raise NmstateInternalError( + "BUG: Accessing MainContext while it is None" + ) + return self._context + + def refresh_content(self): + if self.context: + while self.context.iteration(False): + pass + + def clean_up(self): + if self._cancellable: + self._cancellable.cancel() + self._del_timeout() + self._del_client() + self._context = None + self._cancellable = None + + def _del_client(self): + if self._client: + is_done = [] + is_timeout = [] + self._client.get_context_busy_watcher().weak_ref( + lambda: is_done.append(1) + ) + self._client = None + self._quitting = True + + self.refresh_content() + + if not is_done: + timeout_source = GLib.timeout_source_new(50) + try: + timeout_source.set_callback(lambda x: is_timeout.append(1)) + timeout_source.attach(self.context) + while not is_done and not is_timeout: + self.context.iteration(True) + finally: + timeout_source.destroy() + if not is_done: + logging.error("BUG: NM.Client is not cleaned") + self._context = None + + def _del_timeout(self): + if self._timeout_source: + self._timeout_source.destroy() + self._timeout_source = None + + def register_async(self, action, fast=False): + """ + Register action(string) to wait list. + Set fast as True if requested action does not require too much time, + for example: profile modification. + """ + queue = self._fast_queue if fast else self._slow_queue + max_queue = FAST_ASYNC_QUEUE_SIZE if fast else SLOW_ASYNC_QUEUE_SIZE + if len(queue) >= max_queue: + logging.debug( + f"Async queue({max_queue}) full, waiting all existing actions " + "to be finished before registering more async action" + ) + # TODO: No need to wait all finish, should continue when the queue + # is considerably empty and ready for new async action. + self.wait_all_finish() + + if action in self._fast_queue or action in self._slow_queue: + raise NmstateInternalError( + f"BUG: An existing actions {action} is already registered" + ) + + logging.debug(f"Async action: {action} started") + queue.add(action) + + def finish_async(self, action, suppress_log=False): + """ + Mark action(string) as finished. + """ + self._last_async_finish_time = datetime.datetime.now() + if not suppress_log: + logging.debug(f"Async action: {action} finished") + self._fast_queue.discard(action) + self._slow_queue.discard(action) + + def _action_all_finished(self): + return not (len(self._fast_queue) or len(self._slow_queue)) + + def _idle_timeout_cb(self, _user_data): + if self._error or self._action_all_finished(): + return GLib.SOURCE_REMOVE + idle_time = datetime.datetime.now() - self._last_async_finish_time + if idle_time > datetime.timedelta(seconds=IDLE_TIMEOUT): + remaining_actions = self._slow_queue | self._fast_queue + self.fail( + NmstateTimeoutError(f"Action {remaining_actions} timeout") + ) + return GLib.SOURCE_REMOVE + else: + return GLib.SOURCE_CONTINUE + + def is_cancelled(self): + return self._cancellable.is_cancelled() + + def fail(self, exception): + if not self._cancellable.is_cancelled(): + if self._error: + logging.error( + f"BUG: There is already a exception assigned: " + f"existing: {self._error}, new exception {exception}" + ) + self.cancellable.cancel() + self._del_timeout() + self._error = exception + + def wait_all_finish(self): + """ + Block till all async actions been marked as finished via + `finish_async()` or anyone failed by `fail()`. + """ + self._last_async_finish_time = datetime.datetime.now() + if not self._action_all_finished(): + self._timeout_source = GLib.timeout_source_new( + IDLE_CHECK_INTERNAL * 1000 + ) + user_data = None + self._timeout_source.set_callback(self._idle_timeout_cb, user_data) + self._timeout_source.attach(self._context) + + while not self._action_all_finished() and not self._error: + self.context.iteration(True) + self._del_timeout() + + if self._error: + # The queue and error should be flush and perpare for another run + self._init_queue() + self._init_cancellable() + tmp_error = self._error + self._error = None + # pylint: disable=raising-bad-type + raise tmp_error + # pylint: enable=raising-bad-type + + def get_nm_dev(self, iface_name): + return self.client.get_device_by_iface(iface_name) diff --git a/libnmstate/nm/device.py b/libnmstate/nm/device.py new file mode 100644 index 0000000..528f57d --- /dev/null +++ b/libnmstate/nm/device.py @@ -0,0 +1,163 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import logging + +from libnmstate.error import NmstateLibnmError + +from . import active_connection as ac +from . import connection + + +def activate(context, dev=None, connection_id=None): + """Activate the given device or remote connection profile.""" + conn = connection.ConnectionProfile(context) + conn.nmdevice = dev + conn.con_id = connection_id + conn.activate() + + +def deactivate(context, dev): + """ + Deactivating the current active connection, + The profile itself is not removed. + + For software devices, deactivation removes the devices from the kernel. + """ + act_con = ac.ActiveConnection(context) + act_con.nmdevice = dev + act_con.import_by_device() + act_con.deactivate() + + +def delete(context, dev): + connections = dev.get_available_connections() + for con in connections: + con_profile = connection.ConnectionProfile(context, con) + con_profile.delete() + + +def modify(context, nm_dev, connection_profile): + """ + Modify the given connection profile on the device. + Implemented by the reapply operation with a fallback to the + connection profile activation. + """ + nm_ac = nm_dev.get_active_connection() + if connection.is_activated(nm_ac, nm_dev): + version_id = 0 + flags = 0 + action = f"Reapply device config: {nm_dev.get_iface()}" + context.register_async(action) + user_data = context, nm_dev, action + nm_dev.reapply_async( + connection_profile, + version_id, + flags, + context.cancellable, + _modify_callback, + user_data, + ) + else: + _activate_async(context, nm_dev) + + +def _modify_callback(src_object, result, user_data): + context, nmdev, action = user_data + if context.is_cancelled(): + return + devname = src_object.get_iface() + try: + success = src_object.reapply_finish(result) + except Exception as e: + logging.debug( + "Device reapply failed on %s: error=%s\n" + "Fallback to device activation", + devname, + e, + ) + context.finish_async(action, suppress_log=True) + _activate_async(context, src_object) + return + + if success: + context.finish_async(action) + else: + logging.debug( + "Device reapply failed, fallback to device activation: dev=%s, " + "error='None returned from reapply_finish()'", + devname, + ) + context.finish_async(action, suppress_log=True) + _activate_async(context, src_object) + + +def _activate_async(context, dev): + conn = connection.ConnectionProfile(context) + conn.con_id = dev.get_iface() + conn.nmdevice = dev + if dev: + # Workaround of https://bugzilla.redhat.com/show_bug.cgi?id=1772470 + dev.set_managed(True) + conn.activate() + + +def delete_device(context, nmdev): + iface_name = nmdev.get_iface() + if iface_name: + action = f"Delete device: {nmdev.get_iface()}" + user_data = context, nmdev, action, nmdev.get_iface() + context.register_async(action) + nmdev.delete_async( + context.cancellable, _delete_device_callback, user_data + ) + + +def _delete_device_callback(src_object, result, user_data): + context, nmdev, action, iface_name = user_data + if context.is_cancelled(): + return + error = None + try: + src_object.delete_finish(result) + except Exception as e: + error = e + + if not nmdev.is_real(): + logging.debug("Interface is not real anymore: iface=%s", iface_name) + if error: + logging.debug("Ignored error: %s", error) + context.finish_async(action) + else: + context.fail( + NmstateLibnmError(f"{action} failed: error={error or 'unknown'}") + ) + + +def list_devices(client): + return client.get_devices() + + +def get_device_common_info(dev): + return { + "name": dev.get_iface(), + "type_id": dev.get_device_type(), + "type_name": dev.get_type_description(), + "state": dev.get_state(), + } diff --git a/libnmstate/nm/dns.py b/libnmstate/nm/dns.py new file mode 100644 index 0000000..193c767 --- /dev/null +++ b/libnmstate/nm/dns.py @@ -0,0 +1,124 @@ +# +# Copyright (c) 2019-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from itertools import chain +from operator import itemgetter + +from libnmstate import iplib +from libnmstate.dns import DnsState +from libnmstate.error import NmstateInternalError +from libnmstate.nm import active_connection as nm_ac +from libnmstate.schema import DNS + + +DNS_DEFAULT_PRIORITY_VPN = 50 +DNS_DEFAULT_PRIORITY_OTHER = 100 +DEFAULT_DNS_PRIORITY = 0 +# The 40 is chose as default DHCP DNS priority is 100, and VPN DNS priority is +# 50, the static DNS configuration should be list before them. +DNS_PRIORITY_STATIC_BASE = 40 + +IPV6_ADDRESS_LENGTH = 128 + + +def get_running(context): + dns_state = {DNS.SERVER: [], DNS.SEARCH: []} + for dns_conf in context.get_dns_configuration(): + iface_name = dns_conf.get_interface() + for ns in dns_conf.get_nameservers(): + if iplib.is_ipv6_link_local_addr(ns, IPV6_ADDRESS_LENGTH): + if not iface_name: + # For IPv6 link local address, the interface name should be + # appended also. + raise NmstateInternalError( + "Missing interface for IPv6 link-local DNS server " + "entry {}".format(ns) + ) + ns_addr = "{}%{}".format(ns, iface_name) + else: + ns_addr = ns + if ns_addr not in dns_state[DNS.SERVER]: + dns_state[DNS.SERVER].append(ns_addr) + dns_domains = [ + dns_domain + for dns_domain in dns_conf.get_domains() + if dns_domain not in dns_state[DNS.SEARCH] + ] + dns_state[DNS.SEARCH].extend(dns_domains) + if not dns_state[DNS.SERVER] and not dns_state[DNS.SEARCH]: + dns_state = {} + return dns_state + + +def get_config(acs_and_ipv4_profiles, acs_and_ipv6_profiles): + dns_conf = {DNS.SERVER: [], DNS.SEARCH: []} + tmp_dns_confs = [] + for ac, ip_profile in chain(acs_and_ipv6_profiles, acs_and_ipv4_profiles): + if not ip_profile.props.dns and not ip_profile.props.dns_search: + continue + priority = ip_profile.props.dns_priority + if priority == DEFAULT_DNS_PRIORITY: + # ^ The dns_priority in 'NetworkManager.conf' is been ignored + # due to the lacking of query function in libnm API. + if ac.get_vpn(): + priority = DNS_DEFAULT_PRIORITY_VPN + else: + priority = DNS_DEFAULT_PRIORITY_OTHER + + tmp_dns_confs.append( + { + "server": ip_profile.props.dns, + "priority": priority, + "search": ip_profile.props.dns_search, + } + ) + # NetworkManager sorts the DNS entries based on various criteria including + # which profile was activated first when profiles are activated. Therefore + # the configuration does not completely define the order. To define the + # order in a declarative way, Nmstate only uses the priority to order the + # entries. Reference: + # https://developer.gnome.org/NetworkManager/stable/nm-settings.html#nm-settings.property.ipv4.dns-priority + tmp_dns_confs.sort(key=itemgetter("priority")) + for e in tmp_dns_confs: + dns_conf[DNS.SERVER].extend(e["server"]) + dns_conf[DNS.SEARCH].extend(e["search"]) + if not dns_conf[DNS.SERVER] and dns_conf[DNS.SEARCH]: + return {} + return dns_conf + + +def add_dns(setting_ip, dns_state): + priority = dns_state.get(DnsState.PRIORITY_METADATA) + if priority is not None: + setting_ip.props.dns_priority = priority + DNS_PRIORITY_STATIC_BASE + for server in dns_state.get(DNS.SERVER, []): + setting_ip.add_dns(server) + for search in dns_state.get(DNS.SEARCH, []): + setting_ip.add_dns_search(search) + + +def get_dns_config_iface_names(acs_and_ipv4_profiles, acs_and_ipv6_profiles): + """ + Return a list of interface names which hold static DNS configuration. + """ + iface_names = [] + for ac, ip_profile in chain(acs_and_ipv6_profiles, acs_and_ipv4_profiles): + if ip_profile.props.dns or ip_profile.props.dns_search: + iface_names.append(nm_ac.ActiveConnection(nm_ac_con=ac).devname) + return iface_names diff --git a/libnmstate/nm/ipv4.py b/libnmstate/nm/ipv4.py new file mode 100644 index 0000000..d74f682 --- /dev/null +++ b/libnmstate/nm/ipv4.py @@ -0,0 +1,188 @@ +# +# Copyright (c) 2018-2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import socket + +from libnmstate.nm import dns as nm_dns +from libnmstate.nm import route as nm_route +from libnmstate.schema import InterfaceIPv4 +from libnmstate.schema import Route + +from ..ifaces import BaseIface +from .common import NM + +INT32_MAX = 2 ** 31 - 1 + + +def create_setting(config, base_con_profile): + setting_ipv4 = None + if base_con_profile and config and config.get(InterfaceIPv4.ENABLED): + setting_ipv4 = base_con_profile.get_setting_ip4_config() + if setting_ipv4: + setting_ipv4 = setting_ipv4.duplicate() + setting_ipv4.clear_addresses() + setting_ipv4.props.ignore_auto_routes = False + setting_ipv4.props.never_default = False + setting_ipv4.props.ignore_auto_dns = False + setting_ipv4.props.gateway = None + setting_ipv4.props.route_table = Route.USE_DEFAULT_ROUTE_TABLE + setting_ipv4.props.route_metric = Route.USE_DEFAULT_METRIC + setting_ipv4.clear_routes() + setting_ipv4.clear_routing_rules() + setting_ipv4.clear_dns() + setting_ipv4.clear_dns_searches() + setting_ipv4.props.dns_priority = nm_dns.DEFAULT_DNS_PRIORITY + + if not setting_ipv4: + setting_ipv4 = NM.SettingIP4Config.new() + + setting_ipv4.props.dhcp_client_id = "mac" + setting_ipv4.props.method = NM.SETTING_IP4_CONFIG_METHOD_DISABLED + if config and config.get(InterfaceIPv4.ENABLED): + if config.get(InterfaceIPv4.DHCP): + setting_ipv4.props.method = NM.SETTING_IP4_CONFIG_METHOD_AUTO + setting_ipv4.props.ignore_auto_routes = not config.get( + InterfaceIPv4.AUTO_ROUTES, True + ) + setting_ipv4.props.never_default = not config.get( + InterfaceIPv4.AUTO_GATEWAY, True + ) + setting_ipv4.props.ignore_auto_dns = not config.get( + InterfaceIPv4.AUTO_DNS, True + ) + # NetworkManager will remove the virtual interfaces like bridges + # when the DHCP timeout expired, set it to the maximum value to + # make this unlikely. + setting_ipv4.props.dhcp_timeout = INT32_MAX + elif config.get(InterfaceIPv4.ADDRESS): + setting_ipv4.props.method = NM.SETTING_IP4_CONFIG_METHOD_MANUAL + _add_addresses(setting_ipv4, config[InterfaceIPv4.ADDRESS]) + nm_route.add_routes( + setting_ipv4, config.get(BaseIface.ROUTES_METADATA, []) + ) + nm_dns.add_dns(setting_ipv4, config.get(BaseIface.DNS_METADATA, {})) + nm_route.add_route_rules( + setting_ipv4, + socket.AF_INET, + config.get(BaseIface.ROUTE_RULES_METADATA, []), + ) + return setting_ipv4 + + +def _add_addresses(setting_ipv4, addresses): + for address in addresses: + naddr = NM.IPAddress.new( + socket.AF_INET, + address[InterfaceIPv4.ADDRESS_IP], + address[InterfaceIPv4.ADDRESS_PREFIX_LENGTH], + ) + setting_ipv4.add_address(naddr) + + +def get_info(active_connection): + """ + Provides the current active values for an active connection. + It includes not only the configured values, but the consequences of the + configuration (as in the case of ipv4.method=auto, where the address is + not explicitly defined). + """ + info = {InterfaceIPv4.ENABLED: False} + if active_connection is None: + return info + + ip_profile = get_ip_profile(active_connection) + if ip_profile: + info[InterfaceIPv4.DHCP] = ip_profile.get_method() == ( + NM.SETTING_IP4_CONFIG_METHOD_AUTO + ) + props = ip_profile.props + if info["dhcp"]: + info[InterfaceIPv4.AUTO_ROUTES] = not props.ignore_auto_routes + info[InterfaceIPv4.AUTO_GATEWAY] = not props.never_default + info[InterfaceIPv4.AUTO_DNS] = not props.ignore_auto_dns + info[InterfaceIPv4.ENABLED] = True + info[InterfaceIPv4.ADDRESS] = [] + else: + info[InterfaceIPv4.DHCP] = False + + ip4config = active_connection.get_ip4_config() + if ip4config is None: + if not info[InterfaceIPv4.DHCP]: + del info[InterfaceIPv4.DHCP] + return info + + addresses = [ + { + InterfaceIPv4.ADDRESS_IP: address.get_address(), + InterfaceIPv4.ADDRESS_PREFIX_LENGTH: int(address.get_prefix()), + } + for address in ip4config.get_addresses() + ] + if not addresses: + return info + + info[InterfaceIPv4.ENABLED] = True + info[InterfaceIPv4.ADDRESS] = addresses + return info + + +def get_ip_profile(active_connection): + """ + Get NMSettingIP4Config from NMActiveConnection. + For any error, return None. + """ + remote_conn = active_connection.get_connection() + if remote_conn: + return remote_conn.get_setting_ip4_config() + return None + + +def get_route_running(context): + return nm_route.get_running(_acs_and_ip_cfgs(context)) + + +def get_route_config(context): + return nm_route.get_config(acs_and_ip_profiles(context)) + + +def _acs_and_ip_cfgs(nm_client): + for ac in nm_client.get_active_connections(): + ip_cfg = ac.get_ip4_config() + if not ip_cfg: + continue + yield ac, ip_cfg + + +def acs_and_ip_profiles(nm_client): + for ac in nm_client.get_active_connections(): + ip_profile = get_ip_profile(ac) + if not ip_profile: + continue + yield ac, ip_profile + + +def is_dynamic(active_connection): + ip_profile = get_ip_profile(active_connection) + if ip_profile: + return ip_profile.get_method() == NM.SETTING_IP4_CONFIG_METHOD_AUTO + return False + + +def get_routing_rule_config(nm_client): + return nm_route.get_routing_rule_config(acs_and_ip_profiles(nm_client)) diff --git a/libnmstate/nm/ipv6.py b/libnmstate/nm/ipv6.py new file mode 100644 index 0000000..f252578 --- /dev/null +++ b/libnmstate/nm/ipv6.py @@ -0,0 +1,252 @@ +# +# Copyright (c) 2018-2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import logging +import socket + +from libnmstate import iplib +from libnmstate.error import NmstateNotImplementedError +from libnmstate.nm import dns as nm_dns +from libnmstate.nm import route as nm_route +from libnmstate.schema import InterfaceIPv6 +from libnmstate.schema import Route + +from ..ifaces import BaseIface +from .common import NM + +IPV6_DEFAULT_ROUTE_METRIC = 1024 +INT32_MAX = 2 ** 31 - 1 + + +def get_info(active_connection): + info = {InterfaceIPv6.ENABLED: False} + if active_connection is None: + return info + + info[InterfaceIPv6.DHCP] = False + info[InterfaceIPv6.AUTOCONF] = False + + is_link_local_method = False + ip_profile = get_ip_profile(active_connection) + if ip_profile: + method = ip_profile.get_method() + if method == NM.SETTING_IP6_CONFIG_METHOD_AUTO: + info[InterfaceIPv6.DHCP] = True + info[InterfaceIPv6.AUTOCONF] = True + elif method == NM.SETTING_IP6_CONFIG_METHOD_DHCP: + info[InterfaceIPv6.DHCP] = True + info[InterfaceIPv6.AUTOCONF] = False + elif method == NM.SETTING_IP6_CONFIG_METHOD_LINK_LOCAL: + is_link_local_method = True + elif method == NM.SETTING_IP6_CONFIG_METHOD_DISABLED: + return info + + if info[InterfaceIPv6.DHCP] or info[InterfaceIPv6.AUTOCONF]: + props = ip_profile.props + info[InterfaceIPv6.AUTO_ROUTES] = not props.ignore_auto_routes + info[InterfaceIPv6.AUTO_GATEWAY] = not props.never_default + info[InterfaceIPv6.AUTO_DNS] = not props.ignore_auto_dns + + ipconfig = active_connection.get_ip6_config() + if ipconfig is None: + # When DHCP is enable, it might be possible, the active_connection does + # not got IP address yet. In that case, we still mark + # info[InterfaceIPv6.ENABLED] as True. + if ( + info[InterfaceIPv6.DHCP] + or info[InterfaceIPv6.AUTOCONF] + or is_link_local_method + ): + info[InterfaceIPv6.ENABLED] = True + info[InterfaceIPv6.ADDRESS] = [] + else: + del info[InterfaceIPv6.DHCP] + del info[InterfaceIPv6.AUTOCONF] + return info + + addresses = [ + { + InterfaceIPv6.ADDRESS_IP: address.get_address(), + InterfaceIPv6.ADDRESS_PREFIX_LENGTH: int(address.get_prefix()), + } + for address in ipconfig.get_addresses() + ] + if not addresses: + return info + + info[InterfaceIPv6.ENABLED] = True + info[InterfaceIPv6.ADDRESS] = addresses + return info + + +def create_setting(config, base_con_profile): + setting_ip = None + if base_con_profile and config and config.get(InterfaceIPv6.ENABLED): + setting_ip = base_con_profile.get_setting_ip6_config() + if setting_ip: + setting_ip = setting_ip.duplicate() + setting_ip.clear_addresses() + setting_ip.props.ignore_auto_routes = False + setting_ip.props.never_default = False + setting_ip.props.ignore_auto_dns = False + setting_ip.clear_routes() + setting_ip.props.gateway = None + setting_ip.props.route_table = Route.USE_DEFAULT_ROUTE_TABLE + setting_ip.props.route_metric = Route.USE_DEFAULT_METRIC + setting_ip.clear_dns() + setting_ip.clear_dns_searches() + setting_ip.props.dns_priority = nm_dns.DEFAULT_DNS_PRIORITY + + if not setting_ip: + setting_ip = NM.SettingIP6Config.new() + + # Ensure IPv6 RA and DHCPv6 is based on MAC address only + setting_ip.props.addr_gen_mode = NM.SettingIP6ConfigAddrGenMode.EUI64 + setting_ip.props.dhcp_duid = "ll" + setting_ip.props.dhcp_iaid = "mac" + + if not config or not config.get(InterfaceIPv6.ENABLED): + setting_ip.props.method = NM.SETTING_IP6_CONFIG_METHOD_DISABLED + return setting_ip + + is_dhcp = config.get(InterfaceIPv6.DHCP, False) + is_autoconf = config.get(InterfaceIPv6.AUTOCONF, False) + ip_addresses = config.get(InterfaceIPv6.ADDRESS, ()) + + if is_dhcp or is_autoconf: + _set_dynamic(setting_ip, is_dhcp, is_autoconf) + # NetworkManager will remove the virtual interface when DHCPv6 or + # IPv6-RA timeout, set them to infinity. + setting_ip.props.dhcp_timeout = INT32_MAX + setting_ip.props.ra_timeout = INT32_MAX + setting_ip.props.ignore_auto_routes = not config.get( + InterfaceIPv6.AUTO_ROUTES, True + ) + setting_ip.props.never_default = not config.get( + InterfaceIPv6.AUTO_GATEWAY, True + ) + setting_ip.props.ignore_auto_dns = not config.get( + InterfaceIPv6.AUTO_DNS, True + ) + elif ip_addresses: + _set_static(setting_ip, ip_addresses) + else: + setting_ip.props.method = NM.SETTING_IP6_CONFIG_METHOD_LINK_LOCAL + + nm_route.add_routes(setting_ip, config.get(BaseIface.ROUTES_METADATA, [])) + nm_dns.add_dns(setting_ip, config.get(BaseIface.DNS_METADATA, {})) + nm_route.add_route_rules( + setting_ip, + socket.AF_INET6, + config.get(BaseIface.ROUTE_RULES_METADATA, []), + ) + return setting_ip + + +def _set_dynamic(setting_ip, is_dhcp, is_autoconf): + if not is_dhcp and is_autoconf: + raise NmstateNotImplementedError( + "Autoconf without DHCP is not supported yet" + ) + + if is_dhcp and is_autoconf: + setting_ip.props.method = NM.SETTING_IP6_CONFIG_METHOD_AUTO + elif is_dhcp and not is_autoconf: + setting_ip.props.method = NM.SETTING_IP6_CONFIG_METHOD_DHCP + + +def _set_static(setting_ip, ip_addresses): + for address in ip_addresses: + if iplib.is_ipv6_link_local_addr( + address[InterfaceIPv6.ADDRESS_IP], + address[InterfaceIPv6.ADDRESS_PREFIX_LENGTH], + ): + logging.warning( + "IPv6 link local address " + "{a[ip]}/{a[prefix-length]} is ignored " + "when applying desired state".format(a=address) + ) + else: + naddr = NM.IPAddress.new( + socket.AF_INET6, + address[InterfaceIPv6.ADDRESS_IP], + address[InterfaceIPv6.ADDRESS_PREFIX_LENGTH], + ) + setting_ip.add_address(naddr) + + if setting_ip.props.addresses: + setting_ip.props.method = NM.SETTING_IP6_CONFIG_METHOD_MANUAL + else: + setting_ip.props.method = NM.SETTING_IP6_CONFIG_METHOD_LINK_LOCAL + + +def get_ip_profile(active_connection): + """ + Get NMSettingIP6Config from NMActiveConnection. + For any error, return None. + """ + remote_conn = active_connection.get_connection() + if remote_conn: + return remote_conn.get_setting_ip6_config() + return None + + +def get_route_running(nm_client): + return nm_route.get_running(_acs_and_ip_cfgs(nm_client)) + + +def get_route_config(nm_client): + routes = nm_route.get_config(acs_and_ip_profiles(nm_client)) + for route in routes: + if route[Route.METRIC] == 0: + # Kernel will convert 0 to IPV6_DEFAULT_ROUTE_METRIC. + route[Route.METRIC] = IPV6_DEFAULT_ROUTE_METRIC + + return routes + + +def _acs_and_ip_cfgs(nm_client): + for ac in nm_client.get_active_connections(): + ip_cfg = ac.get_ip6_config() + if not ip_cfg: + continue + yield ac, ip_cfg + + +def acs_and_ip_profiles(nm_client): + for ac in nm_client.get_active_connections(): + ip_profile = get_ip_profile(ac) + if not ip_profile: + continue + yield ac, ip_profile + + +def is_dynamic(active_connection): + ip_profile = get_ip_profile(active_connection) + if ip_profile: + method = ip_profile.get_method() + return method in ( + NM.SETTING_IP6_CONFIG_METHOD_AUTO, + NM.SETTING_IP6_CONFIG_METHOD_DHCP, + ) + return False + + +def get_routing_rule_config(nm_client): + return nm_route.get_routing_rule_config(acs_and_ip_profiles(nm_client)) diff --git a/libnmstate/nm/lldp.py b/libnmstate/nm/lldp.py new file mode 100644 index 0000000..cc31ac2 --- /dev/null +++ b/libnmstate/nm/lldp.py @@ -0,0 +1,371 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from libnmstate.nm import connection +from libnmstate.schema import LLDP + +from .common import NM + + +NM_VLAN_ID_KEY = "vid" +NM_VLAN_NAME_KEY = "name" +NM_MACPHY_AUTONEG_KEY = "autoneg" +NM_MACPHY_PMD_AUTONEG_KEY = "pmd-autoneg-cap" +NM_MACPHY_MAU_TYPE_KEY = "operational-mau-type" +NM_PPVLAN_ID_KEY = "ppvid" +NM_PPVLAN_FLAGS_KEY = "flags" +NM_MANAGEMENT_ADDR_KEY = "address" +NM_MANAGEMENT_ADDR_TYPE_KEY = "address-subtype" +NM_MANAGEMENT_ADDR_IFACE_NUMBER_KEY = "interface-number" +NM_MANAGEMENT_ADDR_IFACE_NUMBER_TYPE_KEY = "interface-number-subtype" +NM_MANAGEMENT_ADDR_TYPE_IPV4 = 1 +NM_MANAGEMENT_ADDR_TYPE_MAC = 6 +NM_INTERFACE_TYPE_IFINDEX = 2 +NM_INTERFACE_TYPE_SYSTEM_PORT = 3 +NM_LLDP_STATUS_DEFAULT = -1 +CHASSIS_TYPE_UNKNOWN = "unknown" +PORT_TYPE_UNKNOWN = "unknown" + +CHASSIS_ID_TLV = 1 +PORT_TLV = 2 +SYSTEM_NAME_TLV = 5 +SYSTEM_DESCRIPTION_TLV = 6 +SYSTEM_CAPABILITIES_TLV = 7 +MANAGEMENT_ADDRESSES_TLV = 8 +ORGANIZATION_SPECIFIC_TLV = 127 + +IEEE = "00:80:c2" +PORT_VLAN_SUBTYPE_TLV = 2 +VLAN_SUBTYPE_TLV = 3 + +IEEE_802_3 = "00:12:0f" +MAC_PHY_SUBTYPE_TLV = 1 +MFS_SUBTYPE_TLV = 4 + +LLDP_CAP_NAMES = { + 0b1: "Other", + 0b10: "Repeater", + 0b100: "MAC Bridge component", + 0b1000: "802.11 Access Point (AP)", + 0b1_0000: "Router", + 0b10_0000: "Telephone", + 0b100_0000: "DOCSIS cable device", + 0b1000_0000: "Station Only", + 0b1_0000_0000: "C-VLAN component", + 0b10_0000_0000: "S-VLAN component", + 0b100_0000_0000: "Two-port MAC Relay component", +} + + +LLDP_CHASSIS_TYPE_TO_NMSTATE = [ + "Reserved", + "Chassis component", + "Interface alias", + "Port component", + "MAC address", + "Network address", + "Interface name", + "Locally assigned", +] + + +LLDP_PORT_TYPE_TO_NMSTATE = [ + "Reserved", + "Interface alias", + "Port component", + "MAC address", + "Network address", + "Interface name", + "Agent circuit ID", + "Locally assigned", +] + + +def apply_lldp_setting(con_setting, iface_desired_state): + lldp_status = iface_desired_state.get(LLDP.CONFIG_SUBTREE, {}).get( + LLDP.ENABLED, None + ) + if lldp_status is not None: + lldp_status = int(lldp_status) + con_setting.setting.props.lldp = lldp_status + + +def get_info(nm_client, nmdev): + """ + Provides the current LLDP neighbors information + """ + lldp_status = _get_lldp_status(nm_client, nmdev) + info = {} + if lldp_status == NM_LLDP_STATUS_DEFAULT or not lldp_status: + info[LLDP.ENABLED] = False + else: + info[LLDP.ENABLED] = True + _get_neighbors_info(info, nmdev) + + return {LLDP.CONFIG_SUBTREE: info} + + +def _get_lldp_status(nm_client, nmdev): + """ + Default means NM global config file value which is by default disabled. + According to NM folks, there is no way from libnm to know if lldp is + enable or not with libnm if the value in the profile is default. + Therefore, the best option is to force the users to enable it explicitly. + This is going to be solved by a property in the NM.Device object to know if + the device is listening on LLDP. + + Ref: https://bugzilla.redhat.com/1832273 + """ + lldp_status = None + con_profile = connection.ConnectionProfile(nm_client) + con_profile.import_by_device(nmdev) + if con_profile.profile: + con_setting = con_profile.profile.get_setting_connection() + if con_setting: + lldp_status = con_setting.get_lldp() + + return lldp_status + + +def _get_neighbors_info(info, nmdev): + neighbors = nmdev.get_lldp_neighbors() + info_neighbors = [] + for neighbor in neighbors: + n_info = [] + _add_neighbor_system_info(neighbor, n_info) + _add_neighbor_chassis_info(neighbor, n_info) + _add_neighbor_port_info(neighbor, n_info) + _add_neighbor_vlans_info(neighbor, n_info) + _add_neighbor_macphy_info(neighbor, n_info) + _add_neighbor_port_vlans_info(neighbor, n_info) + _add_neighbor_management_addresses(neighbor, n_info) + _add_max_frame_size(neighbor, n_info) + info_neighbors.append(n_info) + + if info_neighbors: + info[LLDP.NEIGHBORS_SUBTREE] = info_neighbors + + +def _add_neighbor_system_info(neighbor, info): + sys_name = neighbor.get_attr_value(NM.LLDP_ATTR_SYSTEM_NAME) + if sys_name: + sys_name_object = { + LLDP.Neighbors.TLV_TYPE: SYSTEM_NAME_TLV, + NM.LLDP_ATTR_SYSTEM_NAME: sys_name.get_string(), + } + info.append(sys_name_object) + + sys_desc = neighbor.get_attr_value(NM.LLDP_ATTR_SYSTEM_DESCRIPTION) + if sys_desc: + sys_desc_object = { + LLDP.Neighbors.TLV_TYPE: SYSTEM_DESCRIPTION_TLV, + NM.LLDP_ATTR_SYSTEM_DESCRIPTION: sys_desc.get_string().rstrip(), + } + info.append(sys_desc_object) + + sys_caps = neighbor.get_attr_value(NM.LLDP_ATTR_SYSTEM_CAPABILITIES) + if sys_caps: + sys_caps_object = { + LLDP.Neighbors.TLV_TYPE: SYSTEM_CAPABILITIES_TLV, + NM.LLDP_ATTR_SYSTEM_CAPABILITIES: _decode_sys_caps( + sys_caps.get_uint32() + ), + } + info.append(sys_caps_object) + + +def _decode_sys_caps(code): + capabilities = [] + for mask, capability in LLDP_CAP_NAMES.items(): + if code & mask: + capabilities.append(capability) + return capabilities + + +def _add_neighbor_chassis_info(neighbor, info): + chassis_info = {} + chassis_object = {} + chassis_id = neighbor.get_attr_value(NM.LLDP_ATTR_CHASSIS_ID) + if chassis_id: + chassis_object[NM.LLDP_ATTR_CHASSIS_ID] = chassis_id.get_string() + + chassis_id_type = neighbor.get_attr_value(NM.LLDP_ATTR_CHASSIS_ID_TYPE) + if chassis_id_type: + chassis_object[ + NM.LLDP_ATTR_CHASSIS_ID_TYPE + ] = chassis_id_type.get_uint32() + chassis_object[LLDP.Neighbors.DESCRIPTION] = _decode_chassis_type( + chassis_id_type.get_uint32() + ) + + if chassis_object: + chassis_info[LLDP.Neighbors.TLV_TYPE] = CHASSIS_ID_TLV + chassis_info.update(chassis_object) + info.append(chassis_info) + + +def _decode_chassis_type(code): + try: + return LLDP_CHASSIS_TYPE_TO_NMSTATE[code] + except IndexError: + return CHASSIS_TYPE_UNKNOWN + + +def _add_neighbor_port_info(neighbor, info): + port_info = {} + port_object = {} + port_id = neighbor.get_attr_value(NM.LLDP_ATTR_PORT_ID) + if port_id: + port_object[NM.LLDP_ATTR_PORT_ID] = port_id.get_string() + + port_type = neighbor.get_attr_value(NM.LLDP_ATTR_PORT_ID_TYPE) + if port_type: + port_object[NM.LLDP_ATTR_PORT_ID_TYPE] = port_type.get_uint32() + port_object[LLDP.Neighbors.DESCRIPTION] = _decode_port_type( + port_type.get_uint32() + ) + + if port_object: + port_info[LLDP.Neighbors.TLV_TYPE] = PORT_TLV + port_info.update(port_object) + info.append(port_info) + + +def _decode_port_type(code): + try: + return LLDP_PORT_TYPE_TO_NMSTATE[code] + except IndexError: + return PORT_TYPE_UNKNOWN + + +def _add_neighbor_vlans_info(neighbor, info): + vlans_info = {} + vlan_objects = [] + vlans = neighbor.get_attr_value(NM.LLDP_ATTR_IEEE_802_1_VLANS) + if vlans: + vlans = vlans.unpack() + for vlan in vlans: + vlan_object = vlan.copy() + vlan_object[NM_VLAN_NAME_KEY] = vlan_object[ + NM_VLAN_NAME_KEY + ].replace("\\000", "") + if vlan_object: + vlan_objects.append(vlan_object) + + if vlan_objects: + vlans_info[LLDP.Neighbors.TLV_TYPE] = ORGANIZATION_SPECIFIC_TLV + vlans_info[LLDP.Neighbors.ORGANIZATION_CODE] = IEEE + vlans_info[LLDP.Neighbors.TLV_SUBTYPE] = VLAN_SUBTYPE_TLV + vlans_info[NM.LLDP_ATTR_IEEE_802_1_VLANS] = vlan_objects + info.append(vlans_info) + + +def _add_neighbor_macphy_info(neighbor, info): + macphy_info = {} + macphy_object = {} + macphy_conf = neighbor.get_attr_value(NM.LLDP_ATTR_IEEE_802_3_MAC_PHY_CONF) + if macphy_conf: + macphy_object[NM_MACPHY_AUTONEG_KEY] = bool( + macphy_conf[NM_MACPHY_AUTONEG_KEY] + ) + macphy_object[NM_MACPHY_PMD_AUTONEG_KEY] = macphy_conf[ + NM_MACPHY_PMD_AUTONEG_KEY + ] + macphy_object[NM_MACPHY_MAU_TYPE_KEY] = macphy_conf[ + NM_MACPHY_MAU_TYPE_KEY + ] + + macphy_info[LLDP.Neighbors.TLV_TYPE] = ORGANIZATION_SPECIFIC_TLV + macphy_info[LLDP.Neighbors.ORGANIZATION_CODE] = IEEE_802_3 + macphy_info[LLDP.Neighbors.TLV_SUBTYPE] = MAC_PHY_SUBTYPE_TLV + macphy_info[NM.LLDP_ATTR_IEEE_802_3_MAC_PHY_CONF] = macphy_object + info.append(macphy_info) + + +def _add_neighbor_port_vlans_info(neighbor, info): + port_vlan_objects = [] + port_vlans_info = {} + port_vlans = neighbor.get_attr_value(NM.LLDP_ATTR_IEEE_802_1_PPVIDS) + if port_vlans: + port_vlans = port_vlans.unpack() + for p_vlan in port_vlans: + port_vlan_objects.append(p_vlan[NM_PPVLAN_ID_KEY]) + if port_vlan_objects: + port_vlans_info[ + LLDP.Neighbors.TLV_TYPE + ] = ORGANIZATION_SPECIFIC_TLV + port_vlans_info[LLDP.Neighbors.ORGANIZATION_CODE] = IEEE + port_vlans_info[LLDP.Neighbors.TLV_SUBTYPE] = PORT_VLAN_SUBTYPE_TLV + port_vlans_info[NM.LLDP_ATTR_IEEE_802_1_PPVIDS] = port_vlan_objects + info.append(port_vlans_info) + + +def _add_neighbor_management_addresses(neighbor, info): + addresses_objects = [] + addresses_info = {} + mngt_addresses = neighbor.get_attr_value(NM.LLDP_ATTR_MANAGEMENT_ADDRESSES) + if mngt_addresses: + mngt_addresses = mngt_addresses.unpack() + for mngt_address in mngt_addresses: + mngt_address_info = {} + addr, addr_type = _decode_management_address_type( + mngt_address[NM_MANAGEMENT_ADDR_TYPE_KEY], + mngt_address[NM_MANAGEMENT_ADDR_KEY], + ) + mngt_address_info[NM_MANAGEMENT_ADDR_KEY] = addr + mngt_address_info[NM_MANAGEMENT_ADDR_TYPE_KEY] = addr_type + mngt_address_info[ + NM_MANAGEMENT_ADDR_IFACE_NUMBER_KEY + ] = mngt_address[NM_MANAGEMENT_ADDR_IFACE_NUMBER_KEY] + mngt_address_info[ + NM_MANAGEMENT_ADDR_IFACE_NUMBER_TYPE_KEY + ] = mngt_address[NM_MANAGEMENT_ADDR_IFACE_NUMBER_TYPE_KEY] + addresses_objects.append(mngt_address_info) + if addresses_objects: + addresses_info[LLDP.Neighbors.TLV_TYPE] = MANAGEMENT_ADDRESSES_TLV + addresses_info[ + NM.LLDP_ATTR_MANAGEMENT_ADDRESSES + ] = addresses_objects + info.append(addresses_info) + + +def _add_max_frame_size(neighbor, info): + mfs = neighbor.get_attr_value(NM.LLDP_ATTR_IEEE_802_3_MAX_FRAME_SIZE) + if mfs: + mfs_object = { + LLDP.Neighbors.TLV_TYPE: ORGANIZATION_SPECIFIC_TLV, + LLDP.Neighbors.ORGANIZATION_CODE: IEEE_802_3, + LLDP.Neighbors.TLV_SUBTYPE: MFS_SUBTYPE_TLV, + NM.LLDP_ATTR_IEEE_802_3_MAX_FRAME_SIZE: mfs.get_uint32(), + } + info.append(mfs_object) + + +def _decode_management_address_type(code, address): + if code == NM_MANAGEMENT_ADDR_TYPE_IPV4: + addr = ".".join(map(str, address)) + addr_type = "ipv4" + elif code == NM_MANAGEMENT_ADDR_TYPE_MAC: + addr = ":".join(["{:02X}".format(octet) for octet in address]) + addr_type = "MAC" + else: + addr = ":".join(["{:04X}".format(octet) for octet in address]) + addr_type = "ipv6" + + return addr, addr_type diff --git a/libnmstate/nm/ovs.py b/libnmstate/nm/ovs.py new file mode 100644 index 0000000..2518773 --- /dev/null +++ b/libnmstate/nm/ovs.py @@ -0,0 +1,283 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import logging +from operator import itemgetter + +from libnmstate.schema import OVSBridge as OB +from libnmstate.schema import OVSInterface + +from . import connection +from .common import NM + + +PORT_PROFILE_PREFIX = "ovs-port-" + +NM_OVS_VLAN_MODE_MAP = { + "trunk": OB.Port.Vlan.Mode.TRUNK, + "access": OB.Port.Vlan.Mode.ACCESS, + "native-tagged": OB.Port.Vlan.Mode.TRUNK, + "native-untagged": OB.Port.Vlan.Mode.UNKNOWN, # Not supported yet + "dot1q-tunnel": OB.Port.Vlan.Mode.UNKNOWN, # Not supported yet +} + + +class LacpValue: + ACTIVE = "active" + OFF = "off" + + +def has_ovs_capability(nm_client): + return NM.Capability.OVS in nm_client.get_capabilities() + + +def create_bridge_setting(options_state): + bridge_setting = NM.SettingOvsBridge.new() + for option_name, option_value in options_state.items(): + if option_name == "fail-mode": + if option_value: + bridge_setting.props.fail_mode = option_value + elif option_name == "mcast-snooping-enable": + bridge_setting.props.mcast_snooping_enable = option_value + elif option_name == "rstp": + bridge_setting.props.rstp_enable = option_value + elif option_name == "stp": + bridge_setting.props.stp_enable = option_value + + return bridge_setting + + +def create_port_setting(port_state): + port_setting = NM.SettingOvsPort.new() + + lag_state = port_state.get(OB.Port.LINK_AGGREGATION_SUBTREE) + if lag_state: + mode = lag_state.get(OB.Port.LinkAggregation.MODE) + if mode == OB.Port.LinkAggregation.Mode.LACP: + port_setting.props.lacp = LacpValue.ACTIVE + elif mode in ( + OB.Port.LinkAggregation.Mode.ACTIVE_BACKUP, + OB.Port.LinkAggregation.Mode.BALANCE_SLB, + ): + port_setting.props.lacp = LacpValue.OFF + port_setting.props.bond_mode = mode + elif mode == OB.Port.LinkAggregation.Mode.BALANCE_TCP: + port_setting.props.lacp = LacpValue.ACTIVE + port_setting.props.bond_mode = mode + + down_delay = lag_state.get(OB.Port.LinkAggregation.Options.DOWN_DELAY) + if down_delay: + port_setting.props.bond_downdelay = down_delay + up_delay = lag_state.get(OB.Port.LinkAggregation.Options.UP_DELAY) + if up_delay: + port_setting.props.bond_updelay = up_delay + + vlan_state = port_state.get(OB.Port.VLAN_SUBTREE, {}) + if OB.Port.Vlan.MODE in vlan_state: + if vlan_state[OB.Port.Vlan.MODE] != OB.Port.Vlan.Mode.UNKNOWN: + port_setting.props.vlan_mode = vlan_state[OB.Port.Vlan.MODE] + if OB.Port.Vlan.TAG in vlan_state: + port_setting.props.tag = vlan_state[OB.Port.Vlan.TAG] + + return port_setting + + +def create_interface_setting(patch_state): + interface_setting = NM.SettingOvsInterface.new() + settings = [interface_setting] + + if patch_state and patch_state.get(OVSInterface.Patch.PEER): + interface_setting.props.type = "patch" + settings.append(create_patch_setting(patch_state)) + else: + interface_setting.props.type = "internal" + + return settings + + +def create_patch_setting(patch_state): + patch_setting = NM.SettingOvsPatch.new() + patch_setting.props.peer = patch_state[OVSInterface.Patch.PEER] + + return patch_setting + + +def is_ovs_bridge_type_id(type_id): + return type_id == NM.DeviceType.OVS_BRIDGE + + +def is_ovs_port_type_id(type_id): + return type_id == NM.DeviceType.OVS_PORT + + +def is_ovs_interface_type_id(type_id): + return type_id == NM.DeviceType.OVS_INTERFACE + + +def get_port_by_slave(nmdev): + active_con = connection.get_device_active_connection(nmdev) + if active_con: + master = active_con.get_master() + if master and is_ovs_port_type_id(master.get_device_type()): + return master + return None + + +def get_ovs_info(context, bridge_device, devices_info): + port_profiles = _get_slave_profiles(bridge_device, devices_info) + ports = _get_bridge_ports_info(context, port_profiles, devices_info) + options = _get_bridge_options(context, bridge_device) + + if ports or options: + return {"port": ports, "options": options} + else: + return {} + + +def get_interface_info(act_con): + """ + Get OVS interface information from the NM profile. + """ + info = {} + if act_con: + patch_setting = _get_patch_setting(act_con) + if patch_setting: + info[OVSInterface.PATCH_CONFIG_SUBTREE] = { + OVSInterface.Patch.PEER: patch_setting.props.peer, + } + + return info + + +def _get_patch_setting(act_con): + """ + Get NM.SettingOvsPatch from NM.ActiveConnection. + For any error, return None. + """ + remote_con = act_con.get_connection() + if remote_con: + return remote_con.get_setting_ovs_patch() + + return None + + +def get_slaves(nm_device): + return nm_device.get_slaves() + + +def _get_bridge_ports_info(context, port_profiles, devices_info): + ports_info = [] + for p in port_profiles: + port_info = _get_bridge_port_info(context, p, devices_info) + if port_info: + ports_info.append(port_info) + ports_info.sort(key=itemgetter(OB.Port.NAME)) + return ports_info + + +def _get_bridge_port_info(context, port_profile, devices_info): + """ + Report port information. + Note: The current implementation supports only system OVS ports and + access vlan-mode (trunks are not supported). + """ + port_info = {} + + port_setting = port_profile.get_setting(NM.SettingOvsPort) + vlan_mode = port_setting.props.vlan_mode + + port_name = port_profile.get_interface_name() + port_device = context.get_nm_dev(port_name) + port_slave_profiles = _get_slave_profiles(port_device, devices_info) + port_slave_names = [c.get_interface_name() for c in port_slave_profiles] + + if port_slave_names: + number_of_interfaces = len(port_slave_names) + if number_of_interfaces == 1: + port_info[OB.Port.NAME] = port_slave_names[0] + else: + port_lag_info = _get_lag_info( + port_name, port_setting, port_slave_names + ) + port_info.update(port_lag_info) + + if vlan_mode: + nmstate_vlan_mode = NM_OVS_VLAN_MODE_MAP.get( + vlan_mode, OB.Port.Vlan.Mode.UNKNOWN + ) + if nmstate_vlan_mode == OB.Port.Vlan.Mode.UNKNOWN: + logging.warning( + f"OVS Port VLAN mode '{vlan_mode}' is not supported yet" + ) + port_info[OB.Port.VLAN_SUBTREE] = { + OB.Port.Vlan.MODE: nmstate_vlan_mode, + OB.Port.Vlan.TAG: port_setting.get_tag(), + } + return port_info + + +def _get_lag_info(port_name, port_setting, port_slave_names): + port_info = {} + + lacp = port_setting.props.lacp + mode = port_setting.props.bond_mode + if not mode: + if lacp == LacpValue.ACTIVE: + mode = OB.Port.LinkAggregation.Mode.LACP + else: + mode = OB.Port.LinkAggregation.Mode.ACTIVE_BACKUP + port_info[OB.Port.NAME] = port_name + port_info[OB.Port.LINK_AGGREGATION_SUBTREE] = { + OB.Port.LinkAggregation.MODE: mode, + OB.Port.LinkAggregation.SLAVES_SUBTREE: sorted( + [ + {OB.Port.LinkAggregation.Slave.NAME: iface_name} + for iface_name in port_slave_names + ], + key=itemgetter(OB.Port.LinkAggregation.Slave.NAME), + ), + } + return port_info + + +def _get_bridge_options(context, bridge_device): + bridge_options = {} + con = connection.ConnectionProfile(context) + con.import_by_device(bridge_device) + if con.profile: + bridge_setting = con.profile.get_setting(NM.SettingOvsBridge) + bridge_options["stp"] = bridge_setting.props.stp_enable + bridge_options["rstp"] = bridge_setting.props.rstp_enable + bridge_options["fail-mode"] = bridge_setting.props.fail_mode or "" + bridge_options[ + "mcast-snooping-enable" + ] = bridge_setting.props.mcast_snooping_enable + + return bridge_options + + +def _get_slave_profiles(master_device, devices_info): + slave_profiles = [] + for dev, _ in devices_info: + active_con = connection.get_device_active_connection(dev) + if active_con: + master = active_con.props.master + if master and (master.get_iface() == master_device.get_iface()): + slave_profiles.append(active_con.props.connection) + return slave_profiles diff --git a/libnmstate/nm/plugin.py b/libnmstate/nm/plugin.py new file mode 100644 index 0000000..4032359 --- /dev/null +++ b/libnmstate/nm/plugin.py @@ -0,0 +1,255 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# +from distutils.version import StrictVersion +import logging +from operator import itemgetter + +from libnmstate.error import NmstateValueError +from libnmstate.ifaces.ovs import is_ovs_running +from libnmstate.schema import DNS +from libnmstate.schema import Interface +from libnmstate.schema import Route +from libnmstate.schema import RouteRule +from libnmstate.plugin import NmstatePlugin + +from . import bond as nm_bond +from . import bridge as nm_bridge +from . import connection as nm_connection +from . import device as nm_device +from . import ipv4 as nm_ipv4 +from . import ipv6 as nm_ipv6 +from . import lldp as nm_lldp +from . import ovs as nm_ovs +from . import translator as nm_translator +from . import wired as nm_wired +from . import user as nm_user +from . import vlan as nm_vlan +from . import vxlan as nm_vxlan +from . import team as nm_team +from . import dns as nm_dns +from . import applier as nm_applier +from .checkpoint import CheckPoint +from .checkpoint import get_checkpoints +from .common import NM +from .context import NmContext + + +class NetworkManagerPlugin(NmstatePlugin): + def __init__(self): + self._ctx = NmContext() + self._checkpoint = None + self._check_version_mismatch() + + @property + def name(self): + return "NetworkManager" + + def unload(self): + if self._ctx: + self._ctx.clean_up() + self._ctx = None + + @property + def checkpoint(self): + return self._checkpoint + + @property + def client(self): + return self._ctx.client if self._ctx else None + + @property + def context(self): + return self._ctx + + @property + def capabilities(self): + capabilities = [] + if nm_ovs.has_ovs_capability(self.client) and is_ovs_running(): + capabilities.append(NmstatePlugin.OVS_CAPABILITY) + if nm_team.has_team_capability(self.client): + capabilities.append(NmstatePlugin.TEAM_CAPABILITY) + return capabilities + + @property + def plugin_capabilities(self): + return [ + NmstatePlugin.PLUGIN_CAPABILITY_IFACE, + NmstatePlugin.PLUGIN_CAPABILITY_ROUTE, + NmstatePlugin.PLUGIN_CAPABILITY_ROUTE_RULE, + NmstatePlugin.PLUGIN_CAPABILITY_DNS, + ] + + def get_interfaces(self): + info = [] + capabilities = self.capabilities + + devices_info = [ + (dev, nm_device.get_device_common_info(dev)) + for dev in nm_device.list_devices(self.client) + ] + + for dev, devinfo in devices_info: + type_id = devinfo["type_id"] + + iface_info = nm_translator.Nm2Api.get_common_device_info(devinfo) + + act_con = nm_connection.get_device_active_connection(dev) + iface_info[Interface.IPV4] = nm_ipv4.get_info(act_con) + iface_info[Interface.IPV6] = nm_ipv6.get_info(act_con) + iface_info.update(nm_wired.get_info(dev)) + iface_info.update(nm_user.get_info(self.context, dev)) + iface_info.update(nm_lldp.get_info(self.client, dev)) + iface_info.update(nm_vlan.get_info(dev)) + iface_info.update(nm_vxlan.get_info(dev)) + iface_info.update(nm_bridge.get_info(self.context, dev)) + iface_info.update(nm_team.get_info(dev)) + + if nm_bond.is_bond_type_id(type_id): + bondinfo = nm_bond.get_bond_info(dev) + iface_info.update(_ifaceinfo_bond(bondinfo)) + elif NmstatePlugin.OVS_CAPABILITY in capabilities: + if nm_ovs.is_ovs_bridge_type_id(type_id): + iface_info["bridge"] = nm_ovs.get_ovs_info( + self.context, dev, devices_info + ) + iface_info = _remove_ovs_bridge_unsupported_entries( + iface_info + ) + elif nm_ovs.is_ovs_interface_type_id(type_id): + iface_info.update(nm_ovs.get_interface_info(act_con)) + elif nm_ovs.is_ovs_port_type_id(type_id): + continue + + info.append(iface_info) + + info.sort(key=itemgetter("name")) + + return info + + def get_routes(self): + return { + Route.RUNNING: ( + nm_ipv4.get_route_running(self.client) + + nm_ipv6.get_route_running(self.client) + ), + Route.CONFIG: ( + nm_ipv4.get_route_config(self.client) + + nm_ipv6.get_route_config(self.client) + ), + } + + def get_route_rules(self): + return { + RouteRule.CONFIG: ( + nm_ipv4.get_routing_rule_config(self.client) + + nm_ipv6.get_routing_rule_config(self.client) + ) + } + + def get_dns_client_config(self): + return { + DNS.RUNNING: nm_dns.get_running(self.client), + DNS.CONFIG: nm_dns.get_config( + nm_ipv4.acs_and_ip_profiles(self.client), + nm_ipv6.acs_and_ip_profiles(self.client), + ), + } + + def refresh_content(self): + self._ctx.refresh_content() + + def apply_changes(self, net_state, save_to_disk): + nm_applier.apply_changes(self.context, net_state, save_to_disk) + + def _load_checkpoint(self, checkpoint_path): + if checkpoint_path: + if self._checkpoint: + # Old checkpoint might timeout, hence it's legal to load + # another one. + self._checkpoint.clean_up() + candidates = get_checkpoints(self._ctx.client) + if checkpoint_path in candidates: + self._checkpoint = CheckPoint( + nm_context=self._ctx, dbuspath=checkpoint_path + ) + else: + raise NmstateValueError("No checkpoint specified or found") + else: + if not self._checkpoint: + # Get latest one + candidates = get_checkpoints(self._ctx.client) + if candidates: + self._checkpoint = CheckPoint( + nm_context=self._ctx, dbuspath=candidates[0] + ) + else: + raise NmstateValueError("No checkpoint specified or found") + + def create_checkpoint(self, timeout=60): + self._checkpoint = CheckPoint.create(self._ctx, timeout) + return str(self._checkpoint) + + def rollback_checkpoint(self, checkpoint=None): + self._load_checkpoint(checkpoint) + self._checkpoint.rollback() + self._checkpoint = None + + def destroy_checkpoint(self, checkpoint=None): + self._load_checkpoint(checkpoint) + self._checkpoint.destroy() + self._checkpoint = None + + def _check_version_mismatch(self): + nm_client_version = self._ctx.client.get_version() + nm_utils_version = _nm_utils_decode_version() + + if nm_client_version is None: + logging.warning("NetworkManager is not running") + elif StrictVersion(nm_client_version) != StrictVersion( + nm_utils_version + ): + logging.warning( + "libnm version %s mismatches NetworkManager version %s", + nm_utils_version, + nm_client_version, + ) + + +def _ifaceinfo_bond(devinfo): + # TODO: What about unmanaged devices? + bondinfo = nm_translator.Nm2Api.get_bond_info(devinfo) + if "link-aggregation" in bondinfo: + return bondinfo + return {} + + +def _remove_ovs_bridge_unsupported_entries(iface_info): + """ + OVS bridges are not supporting several common interface key entries. + These entries are removed explicitly. + """ + iface_info.pop(Interface.IPV4, None) + iface_info.pop(Interface.IPV6, None) + iface_info.pop(Interface.MTU, None) + + return iface_info + + +def _nm_utils_decode_version(): + return f"{NM.MAJOR_VERSION}.{NM.MINOR_VERSION}.{NM.MICRO_VERSION}" diff --git a/libnmstate/nm/route.py b/libnmstate/nm/route.py new file mode 100644 index 0000000..53bcf7c --- /dev/null +++ b/libnmstate/nm/route.py @@ -0,0 +1,284 @@ +# +# Copyright (c) 2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from operator import itemgetter +import socket + +from libnmstate import iplib +from libnmstate.error import NmstateNotImplementedError +from libnmstate.error import NmstateValueError +from libnmstate.nm import active_connection as nm_ac +from libnmstate.schema import Interface +from libnmstate.schema import Route +from libnmstate.schema import RouteRule + +from .common import GLib +from .common import NM + +NM_ROUTE_TABLE_ATTRIBUTE = "table" +IPV4_DEFAULT_GATEWAY_DESTINATION = "0.0.0.0/0" +IPV6_DEFAULT_GATEWAY_DESTINATION = "::/0" + +# NM require route rule priority been set explicitly, use 30,000 when +# desire state instruct to use USE_DEFAULT_PRIORITY +ROUTE_RULE_DEFAULT_PRIORIRY = 30000 + + +def get_running(acs_and_ip_cfgs): + """ + Query running routes + The acs_and_ip_cfgs should be generate to generate a tuple: + NM.NM.ActiveConnection, NM.IPConfig + """ + routes = [] + for (active_connection, ip_cfg) in acs_and_ip_cfgs: + if not ip_cfg.props.routes: + continue + iface_name = nm_ac.ActiveConnection( + nm_ac_con=active_connection + ).devname + if not iface_name: + continue + for nm_route in ip_cfg.props.routes: + table_id = _get_per_route_table_id( + nm_route, iplib.KERNEL_MAIN_ROUTE_TABLE_ID + ) + route_entry = _nm_route_to_route(nm_route, table_id, iface_name) + if route_entry: + routes.append(route_entry) + routes.sort( + key=itemgetter( + Route.TABLE_ID, Route.NEXT_HOP_INTERFACE, Route.DESTINATION + ) + ) + return routes + + +def get_config(acs_and_ip_profiles): + """ + Query running routes + The acs_and_ip_profiles should be generate to generate a tuple: + NM.NM.ActiveConnection, NM.SettingIPConfig + """ + routes = [] + for (active_connection, ip_profile) in acs_and_ip_profiles: + nm_routes = ip_profile.props.routes + gateway = ip_profile.props.gateway + if not nm_routes and not gateway: + continue + iface_name = nm_ac.ActiveConnection( + nm_ac_con=active_connection + ).devname + if not iface_name: + continue + default_table_id = ip_profile.props.route_table + if gateway: + routes.append( + _get_default_route_config( + gateway, + ip_profile.props.route_metric, + default_table_id, + iface_name, + ) + ) + # NM supports multiple route table in single profile: + # https://bugzilla.redhat.com/show_bug.cgi?id=1436531 + # The `ipv4.route-table` and `ipv6.route-table` will be the default + # table id for static routes and auto routes. But each static route can + # still specify route table id. + for nm_route in nm_routes: + table_id = _get_per_route_table_id(nm_route, default_table_id) + route_entry = _nm_route_to_route(nm_route, table_id, iface_name) + if route_entry: + routes.append(route_entry) + routes.sort( + key=itemgetter( + Route.TABLE_ID, Route.NEXT_HOP_INTERFACE, Route.DESTINATION + ) + ) + return routes + + +def _get_per_route_table_id(nm_route, default_table_id): + table = nm_route.get_attribute(NM_ROUTE_TABLE_ATTRIBUTE) + return int(table.get_uint32()) if table else default_table_id + + +def _nm_route_to_route(nm_route, table_id, iface_name): + dst = "{ip}/{prefix}".format( + ip=nm_route.get_dest(), prefix=nm_route.get_prefix() + ) + next_hop = nm_route.get_next_hop() or "" + metric = int(nm_route.get_metric()) + + return { + Route.TABLE_ID: table_id, + Route.DESTINATION: dst, + Route.NEXT_HOP_INTERFACE: iface_name, + Route.NEXT_HOP_ADDRESS: next_hop, + Route.METRIC: metric, + } + + +def _get_default_route_config(gateway, metric, default_table_id, iface_name): + if iplib.is_ipv6_address(gateway): + destination = IPV6_DEFAULT_GATEWAY_DESTINATION + else: + destination = IPV4_DEFAULT_GATEWAY_DESTINATION + return { + Route.TABLE_ID: default_table_id, + Route.DESTINATION: destination, + Route.NEXT_HOP_INTERFACE: iface_name, + Route.NEXT_HOP_ADDRESS: gateway, + Route.METRIC: metric, + } + + +def add_routes(setting_ip, routes): + for route in routes: + if route[Route.DESTINATION] in ( + IPV4_DEFAULT_GATEWAY_DESTINATION, + IPV6_DEFAULT_GATEWAY_DESTINATION, + ): + if setting_ip.get_gateway(): + raise NmstateNotImplementedError( + "Only a single default gateway is supported due to a " + "limitation of NetworkManager: " + "https://bugzilla.redhat.com/1707396" + ) + _add_route_gateway(setting_ip, route) + else: + _add_specfic_route(setting_ip, route) + + +def _add_specfic_route(setting_ip, route): + destination, prefix_len = route[Route.DESTINATION].split("/") + prefix_len = int(prefix_len) + if iplib.is_ipv6_address(destination): + family = socket.AF_INET6 + else: + family = socket.AF_INET + metric = route.get(Route.METRIC, Route.USE_DEFAULT_METRIC) + next_hop = route[Route.NEXT_HOP_ADDRESS] + ip_route = NM.IPRoute.new( + family, destination, prefix_len, next_hop, metric + ) + table_id = route.get(Route.TABLE_ID, Route.USE_DEFAULT_ROUTE_TABLE) + ip_route.set_attribute( + NM_ROUTE_TABLE_ATTRIBUTE, GLib.Variant.new_uint32(table_id) + ) + # Duplicate route entry will be ignored by libnm. + setting_ip.add_route(ip_route) + + +def _add_route_gateway(setting_ip, route): + setting_ip.props.gateway = route[Route.NEXT_HOP_ADDRESS] + setting_ip.props.route_table = route.get( + Route.TABLE_ID, Route.USE_DEFAULT_ROUTE_TABLE + ) + setting_ip.props.route_metric = route.get( + Route.METRIC, Route.USE_DEFAULT_METRIC + ) + + +def get_static_gateway_iface(family, iface_routes): + """ + Return one interface with gateway for given IP family. + Return None if not found. + """ + destination = ( + IPV6_DEFAULT_GATEWAY_DESTINATION + if family == Interface.IPV6 + else IPV4_DEFAULT_GATEWAY_DESTINATION + ) + for iface_name, routes in iface_routes.items(): + for route in routes: + if route[Route.DESTINATION] == destination: + return iface_name + return None + + +def get_routing_rule_config(acs_and_ip_profiles): + rules = [] + for (_, ip_profile) in acs_and_ip_profiles: + for i in range(ip_profile.get_num_routing_rules()): + nm_rule = ip_profile.get_routing_rule(i) + rules.append(_nm_rule_to_info(nm_rule)) + + return rules + + +def _nm_rule_to_info(nm_rule): + info = { + RouteRule.IP_FROM: _nm_rule_get_from(nm_rule), + RouteRule.IP_TO: _nm_rule_get_to(nm_rule), + RouteRule.PRIORITY: nm_rule.get_priority(), + RouteRule.ROUTE_TABLE: nm_rule.get_table(), + } + cleanup_keys = [key for key, val in info.items() if val is None] + for key in cleanup_keys: + del info[key] + + return info + + +def _nm_rule_get_from(nm_rule): + if nm_rule.get_from(): + return iplib.to_ip_address_full( + nm_rule.get_from(), nm_rule.get_from_len() + ) + return None + + +def _nm_rule_get_to(nm_rule): + if nm_rule.get_to(): + return iplib.to_ip_address_full(nm_rule.get_to(), nm_rule.get_to_len()) + return None + + +def add_route_rules(setting_ip, family, rules): + for rule in rules: + setting_ip.add_routing_rule(_rule_info_to_nm_rule(rule, family)) + + +def _rule_info_to_nm_rule(rule, family): + nm_rule = NM.IPRoutingRule.new(family) + ip_from = rule.get(RouteRule.IP_FROM) + ip_to = rule.get(RouteRule.IP_TO) + if not ip_from and not ip_to: + raise NmstateValueError( + f"Neither {RouteRule.IP_FROM} or {RouteRule.IP_TO} is defined" + ) + + if ip_from: + nm_rule.set_from(*iplib.ip_address_full_to_tuple(ip_from)) + if ip_to: + nm_rule.set_to(*iplib.ip_address_full_to_tuple(ip_to)) + + priority = rule.get(RouteRule.PRIORITY) + if priority and priority != RouteRule.USE_DEFAULT_PRIORITY: + nm_rule.set_priority(priority) + else: + nm_rule.set_priority(ROUTE_RULE_DEFAULT_PRIORIRY) + table = rule.get(RouteRule.ROUTE_TABLE) + if table and table != RouteRule.USE_DEFAULT_ROUTE_TABLE: + nm_rule.set_table(table) + else: + nm_rule.set_table(iplib.KERNEL_MAIN_ROUTE_TABLE_ID) + return nm_rule diff --git a/libnmstate/nm/sriov.py b/libnmstate/nm/sriov.py new file mode 100644 index 0000000..f544732 --- /dev/null +++ b/libnmstate/nm/sriov.py @@ -0,0 +1,207 @@ +# +# Copyright (c) 2019-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import re +import subprocess + +from libnmstate.error import NmstateNotSupportedError +from libnmstate.schema import Ethernet +from libnmstate.schema import Interface + +from .common import NM +from .common import GLib + + +SRIOV_NMSTATE_TO_NM_MAP = { + Ethernet.SRIOV.VFS.MAC_ADDRESS: ( + NM.SRIOV_VF_ATTRIBUTE_MAC, + GLib.Variant.new_string, + ), + Ethernet.SRIOV.VFS.SPOOF_CHECK: ( + NM.SRIOV_VF_ATTRIBUTE_SPOOF_CHECK, + GLib.Variant.new_boolean, + ), + Ethernet.SRIOV.VFS.TRUST: ( + NM.SRIOV_VF_ATTRIBUTE_TRUST, + GLib.Variant.new_boolean, + ), + Ethernet.SRIOV.VFS.MIN_TX_RATE: ( + NM.SRIOV_VF_ATTRIBUTE_MIN_TX_RATE, + GLib.Variant.new_uint32, + ), + Ethernet.SRIOV.VFS.MAX_TX_RATE: ( + NM.SRIOV_VF_ATTRIBUTE_MAX_TX_RATE, + GLib.Variant.new_uint32, + ), +} + +SRIOV_NMSTATE_TO_REGEX = { + Ethernet.SRIOV.VFS.MAC_ADDRESS: re.compile( + r"[a-fA-F0-9:]{17}|[a-fA-F0-9]{12}" + ), + Ethernet.SRIOV.VFS.SPOOF_CHECK: re.compile(r"checking (on|off)"), + Ethernet.SRIOV.VFS.TRUST: re.compile(r"trust (on|off)"), + Ethernet.SRIOV.VFS.MIN_TX_RATE: re.compile(r"min_tx_rate ([0-9]+)"), + Ethernet.SRIOV.VFS.MAX_TX_RATE: re.compile(r"max_tx_rate ([0-9]+)"), +} + + +def create_setting(context, iface_state, base_con_profile): + sriov_setting = None + ifname = iface_state[Interface.NAME] + sriov_config = iface_state.get(Ethernet.CONFIG_SUBTREE, {}).get( + Ethernet.SRIOV_SUBTREE + ) + if sriov_config: + if not _has_sriov_capability(context, ifname): + raise NmstateNotSupportedError( + f"Interface '{ifname}' does not support SR-IOV" + ) + + sriov_setting = base_con_profile.get_setting_duplicate( + NM.SETTING_SRIOV_SETTING_NAME + ) + if not sriov_setting: + sriov_setting = NM.SettingSriov.new() + + vfs_config = sriov_config.get(Ethernet.SRIOV.VFS_SUBTREE, []) + vf_object_ids = {vf.get_index() for vf in sriov_setting.props.vfs} + vf_config_ids = { + vf_config[Ethernet.SRIOV.VFS.ID] for vf_config in vfs_config + } + + # As the user must do full edit of vfs, nmstate is deleting all the vfs + # and then adding all the vfs from the config. + for vf_id in _remove_sriov_vfs_in_setting( + vfs_config, sriov_setting, vf_object_ids + ): + sriov_setting.remove_vf_by_index(vf_id) + + for vf_object in _create_sriov_vfs_from_config( + vfs_config, sriov_setting, vf_config_ids + ): + sriov_setting.add_vf(vf_object) + + sriov_setting.props.total_vfs = sriov_config[Ethernet.SRIOV.TOTAL_VFS] + + return sriov_setting + + +def _create_sriov_vfs_from_config(vfs_config, sriov_setting, vf_ids_to_add): + vfs_config_to_add = ( + vf_config + for vf_config in vfs_config + if vf_config[Ethernet.SRIOV.VFS.ID] in vf_ids_to_add + ) + for vf_config in vfs_config_to_add: + vf_id = vf_config.pop(Ethernet.SRIOV.VFS.ID) + vf_object = NM.SriovVF.new(vf_id) + for key, val in vf_config.items(): + _set_nm_attribute(vf_object, key, val) + + yield vf_object + + +def _set_nm_attribute(vf_object, key, value): + nm_attr, nm_variant = SRIOV_NMSTATE_TO_NM_MAP[key] + vf_object.set_attribute(nm_attr, nm_variant(value)) + + +def _remove_sriov_vfs_in_setting(vfs_config, sriov_setting, vf_ids_to_remove): + for vf_id in vf_ids_to_remove: + yield vf_id + + +def _has_sriov_capability(context, ifname): + dev = context.get_nm_dev(ifname) + return dev and (NM.DeviceCapabilities.SRIOV & dev.props.capabilities) + + +def get_info(device): + """ + Provide the current active SR-IOV runtime values + """ + sriov_running_info = {} + + ifname = device.get_iface() + numvf_path = f"/sys/class/net/{ifname}/device/sriov_numvfs" + try: + with open(numvf_path) as f: + sriov_running_info[Ethernet.SRIOV.TOTAL_VFS] = int(f.read()) + except FileNotFoundError: + return sriov_running_info + + if sriov_running_info[Ethernet.SRIOV.TOTAL_VFS]: + sriov_running_info[Ethernet.SRIOV.VFS_SUBTREE] = _get_sriov_vfs_info( + ifname + ) + else: + sriov_running_info[Ethernet.SRIOV.VFS_SUBTREE] = [] + + return {Ethernet.SRIOV_SUBTREE: sriov_running_info} + + +def _get_sriov_vfs_info(ifname): + """ + This is a workaround to get the VFs configuration from runtime. + Ref: https://bugzilla.redhat.com/1777520 + """ + proc = subprocess.run( + ("ip", "link", "show", ifname), + stdout=subprocess.PIPE, + encoding="utf-8", + ) + iplink_output = proc.stdout + + # This is ignoring the first two line of the ip link output because they + # are about the PF and we don't need them. + vfs = iplink_output.splitlines(False)[2:] + vfs_config = [ + vf_config for vf_config in _parse_ip_link_output_for_vfs(vfs) + ] + + return vfs_config + + +def _parse_ip_link_output_for_vfs(vfs): + for vf_id, vf in enumerate(vfs): + vf_config = _parse_ip_link_output_options_for_vf(vf) + vf_config[Ethernet.SRIOV.VFS.ID] = vf_id + yield vf_config + + +def _parse_ip_link_output_options_for_vf(vf): + vf_options = {} + for option, expr in SRIOV_NMSTATE_TO_REGEX.items(): + match_expr = expr.search(vf) + if match_expr: + if option == Ethernet.SRIOV.VFS.MAC_ADDRESS: + value = match_expr.group(0).upper() + else: + value = match_expr.group(1) + + if value.isdigit(): + value = int(value) + elif value == "on": + value = True + elif value == "off": + value = False + vf_options[option] = value + + return vf_options diff --git a/libnmstate/nm/team.py b/libnmstate/nm/team.py new file mode 100644 index 0000000..1ac22da --- /dev/null +++ b/libnmstate/nm/team.py @@ -0,0 +1,105 @@ +# +# Copyright (c) 2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import copy +import json + +from libnmstate.schema import Interface +from libnmstate.schema import Team + +from .common import NM + +TEAMD_JSON_DEVICE = "device" +TEAMD_JSON_PORTS = "ports" + + +def has_team_capability(nm_client): + return NM.Capability.TEAM in nm_client.get_capabilities() + + +def create_setting(iface_state, base_con_profile): + team_setting = None + team_config = iface_state.get(Team.CONFIG_SUBTREE) + + if not team_config: + return None + + if base_con_profile: + team_setting = base_con_profile.get_setting_duplicate( + NM.SETTING_TEAM_SETTING_NAME + ) + + if not team_setting: + team_setting = NM.SettingTeam.new() + + teamd_config = _convert_team_config_to_teamd_format( + team_config, iface_state[Interface.NAME] + ) + + team_setting.props.config = json.dumps(teamd_config) + + return team_setting + + +def _convert_team_config_to_teamd_format(team_config, ifname): + team_config = copy.deepcopy(team_config) + team_config[TEAMD_JSON_DEVICE] = ifname + + team_ports = team_config.get(Team.PORT_SUBTREE, ()) + team_ports_formatted = { + port[Team.Port.NAME]: _dict_key_filter(port, Team.Port.NAME) + for port in team_ports + } + team_config[Team.PORT_SUBTREE] = team_ports_formatted + + return team_config + + +def _dict_key_filter(dict_to_filter, key): + return dict(filter(lambda elem: elem[0] == key, dict_to_filter.items())) + + +def get_info(device): + """ + Provide the current active teamd values for an interface. Please note that + these values might be outdated due to the bug below. + Ref: https://bugzilla.redhat.com/1792232 + """ + info = {} + + if device.get_device_type() == NM.DeviceType.TEAM: + teamd_json = device.get_config() + if teamd_json: + teamd_config = json.loads(teamd_json) + slave_names = [dev.get_iface() for dev in device.get_slaves()] + info[Team.CONFIG_SUBTREE] = { + Team.PORT_SUBTREE: [ + {Team.Port.NAME: n} for n in sorted(slave_names) + ], + } + runner = _get_runner_name(teamd_config) + if runner: + info[Team.CONFIG_SUBTREE][Team.RUNNER_SUBTREE] = { + Team.Runner.NAME: runner + } + return info + + +def _get_runner_name(teamd_config): + return teamd_config.get("runner", {}).get("name") diff --git a/libnmstate/nm/translator.py b/libnmstate/nm/translator.py new file mode 100644 index 0000000..24008ef --- /dev/null +++ b/libnmstate/nm/translator.py @@ -0,0 +1,125 @@ +# +# Copyright (c) 2018-2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import copy + +from .common import NM + + +IFACE_TYPE_UNKNOWN = "unknown" + + +class ApiIfaceAdminState: + DOWN = "down" + UP = "up" + + +class Api2Nm: + _iface_types_map = None + + @staticmethod + def get_iface_type(name): + return Api2Nm.get_iface_type_map().get(name, IFACE_TYPE_UNKNOWN) + + @staticmethod + def get_iface_type_map(): + if Api2Nm._iface_types_map is None: + Api2Nm._iface_types_map = { + "ethernet": NM.SETTING_WIRED_SETTING_NAME, + "bond": NM.SETTING_BOND_SETTING_NAME, + "dummy": NM.SETTING_DUMMY_SETTING_NAME, + "team": NM.SETTING_TEAM_SETTING_NAME, + "vlan": NM.SETTING_VLAN_SETTING_NAME, + "vxlan": NM.SETTING_VXLAN_SETTING_NAME, + "linux-bridge": NM.SETTING_BRIDGE_SETTING_NAME, + } + try: + ovs_types = { + "ovs-bridge": NM.SETTING_OVS_BRIDGE_SETTING_NAME, + "ovs-port": NM.SETTING_OVS_PORT_SETTING_NAME, + "ovs-interface": NM.SETTING_OVS_INTERFACE_SETTING_NAME, + } + Api2Nm._iface_types_map.update(ovs_types) + except AttributeError: + pass + + return Api2Nm._iface_types_map + + @staticmethod + def get_bond_options(iface_desired_state): + iface_type = Api2Nm.get_iface_type(iface_desired_state["type"]) + if iface_type == "bond": + # Is the mode a must config parameter? + bond_conf = iface_desired_state["link-aggregation"] + bond_opts = {"mode": bond_conf["mode"]} + bond_opts.update(bond_conf.get("options", {})) + else: + bond_opts = {} + + return bond_opts + + +class Nm2Api: + _iface_types_map = None + + @staticmethod + def get_common_device_info(devinfo): + type_name = devinfo["type_name"] + if type_name != "ethernet": + type_name = Nm2Api.get_iface_type(type_name) + return { + "name": devinfo["name"], + "type": type_name, + "state": Nm2Api.get_iface_admin_state(devinfo["state"]), + } + + @staticmethod + def get_bond_info(bondinfo): + bond_options = copy.deepcopy(bondinfo.get("options")) + if not bond_options: + return {} + bond_slaves = bondinfo["slaves"] + + bond_mode = bond_options["mode"] + del bond_options["mode"] + return { + "link-aggregation": { + "mode": bond_mode, + "slaves": [slave.props.interface for slave in bond_slaves], + "options": bond_options, + } + } + + @staticmethod + def get_iface_type(name): + if Nm2Api._iface_types_map is None: + Nm2Api._iface_types_map = Nm2Api._swap_dict_keyval( + Api2Nm.get_iface_type_map() + ) + return Nm2Api._iface_types_map.get(name, IFACE_TYPE_UNKNOWN) + + @staticmethod + def get_iface_admin_state(dev_state): + if NM.DeviceState.IP_CONFIG <= dev_state <= NM.DeviceState.ACTIVATED: + return ApiIfaceAdminState.UP + return ApiIfaceAdminState.DOWN + + @staticmethod + def _swap_dict_keyval(dictionary): + return {val: key for key, val in dictionary.items()} diff --git a/libnmstate/nm/user.py b/libnmstate/nm/user.py new file mode 100644 index 0000000..9cd1229 --- /dev/null +++ b/libnmstate/nm/user.py @@ -0,0 +1,77 @@ +# +# Copyright (c) 2018-2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# +""" Use a Network Manager User Setting to store and retrieve information that +does not fit somewhere else such as an interface's description. + +https://lazka.github.io/pgi-docs/#NM-1.0/classes/SettingUser.html +""" + +from libnmstate.error import NmstateValueError +from libnmstate.nm import connection as nm_connection +from .common import NM + +NMSTATE_DESCRIPTION = "nmstate.interface.description" + + +def create_setting(iface_state, base_con_profile): + description = iface_state.get("description") + + if not description: + return None + + if not NM.SettingUser.check_val(description): + raise NmstateValueError("Invalid description") + + user_setting = None + if base_con_profile: + user_setting = base_con_profile.get_setting_by_name( + NM.SETTING_USER_SETTING_NAME + ) + if user_setting: + user_setting = user_setting.duplicate() + + if not user_setting: + user_setting = NM.SettingUser.new() + + user_setting.set_data(NMSTATE_DESCRIPTION, description) + return user_setting + + +def get_info(context, device): + """ + Get description from user settings for a connection + """ + info = {} + + connection = nm_connection.ConnectionProfile(context) + connection.import_by_device(device) + if not connection.profile: + return info + + try: + user_setting = connection.profile.get_setting_by_name( + NM.SETTING_USER_SETTING_NAME + ) + description = user_setting.get_data(NMSTATE_DESCRIPTION) + if description: + info["description"] = description + except AttributeError: + pass + + return info diff --git a/libnmstate/nm/vlan.py b/libnmstate/nm/vlan.py new file mode 100644 index 0000000..5e4a956 --- /dev/null +++ b/libnmstate/nm/vlan.py @@ -0,0 +1,57 @@ +# +# Copyright (c) 2018-2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from libnmstate.schema import VLAN +from .common import NM + + +def create_setting(iface_state, base_con_profile): + vlan = iface_state.get(VLAN.TYPE) + if not vlan: + return None + + vlan_id = vlan[VLAN.ID] + vlan_base_iface = vlan[VLAN.BASE_IFACE] + + vlan_setting = None + if base_con_profile: + vlan_setting = base_con_profile.get_setting_vlan() + if vlan_setting: + vlan_setting = vlan_setting.duplicate() + + if not vlan_setting: + vlan_setting = NM.SettingVlan.new() + + vlan_setting.props.id = vlan_id + vlan_setting.props.parent = vlan_base_iface + + return vlan_setting + + +def get_info(device): + """ + Provides the current active values for a device + """ + info = {} + if device.get_device_type() == NM.DeviceType.VLAN: + info[VLAN.CONFIG_SUBTREE] = { + VLAN.ID: device.props.vlan_id, + VLAN.BASE_IFACE: device.props.parent.get_iface(), + } + return info diff --git a/libnmstate/nm/vxlan.py b/libnmstate/nm/vxlan.py new file mode 100644 index 0000000..0901b54 --- /dev/null +++ b/libnmstate/nm/vxlan.py @@ -0,0 +1,76 @@ +# +# Copyright (c) 2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from libnmstate.schema import VXLAN +from .common import NM + + +def create_setting(iface_state, base_con_profile): + vxlan = iface_state.get(VXLAN.CONFIG_SUBTREE) + if not vxlan: + return None + + vxlan_setting = None + if base_con_profile: + vxlan_setting = base_con_profile.get_setting_vxlan() + if vxlan_setting: + vxlan_setting = vxlan_setting.duplicate() + + if not vxlan_setting: + vxlan_setting = NM.SettingVxlan.new() + + vxlan_setting.props.id = vxlan[VXLAN.ID] + vxlan_setting.props.parent = vxlan[VXLAN.BASE_IFACE] + vxlan_remote = vxlan.get(VXLAN.REMOTE) + if vxlan_remote: + vxlan_setting.props.remote = vxlan_remote + vxlan_destination_port = vxlan.get(VXLAN.DESTINATION_PORT) + if vxlan_destination_port: + vxlan_setting.props.destination_port = vxlan_destination_port + + return vxlan_setting + + +def get_info(device): + """ + Provides the current active values for a device + """ + if device.get_device_type() == NM.DeviceType.VXLAN: + base_iface = "" + if device.props.parent: + base_iface = device.props.parent.get_iface() + remote = device.props.group + if not remote: + remote = "" + return { + VXLAN.CONFIG_SUBTREE: { + VXLAN.ID: device.props.id, + VXLAN.BASE_IFACE: base_iface, + VXLAN.REMOTE: remote, + VXLAN.DESTINATION_PORT: _get_destination_port(device), + } + } + return {} + + +def _get_destination_port(device): + """ + Retrieve the destination port. + """ + return device.get_dst_port() diff --git a/libnmstate/nm/wired.py b/libnmstate/nm/wired.py new file mode 100644 index 0000000..27d4318 --- /dev/null +++ b/libnmstate/nm/wired.py @@ -0,0 +1,192 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from libnmstate.ethtool import minimal_ethtool +from libnmstate.nm import sriov +from libnmstate.schema import Ethernet +from libnmstate.schema import Interface +from .common import NM + + +ZEROED_MAC = "00:00:00:00:00:00" + + +class WiredSetting: + def __init__(self, state): + self.mtu = state.get(Interface.MTU) + self.mac = state.get(Interface.MAC) + + ethernet = state.get(Ethernet.CONFIG_SUBTREE, {}) + self.speed = ethernet.get(Ethernet.SPEED) + self.duplex = ethernet.get(Ethernet.DUPLEX) + self.auto_negotiation = ethernet.get(Ethernet.AUTO_NEGOTIATION) + + def __hash__(self): + return hash(self.__key()) + + def __eq__(self, other): + return self is other or self.__key() == other.__key() + + def __ne__(self, other): + return not self.__eq__(other) + + def __bool__(self): + return bool( + self.mac + or self.mtu + or self.speed + or self.duplex + or (self.auto_negotiation is not None) + ) + + def __key(self): + return ( + self.mtu, + self.mac, + self.speed, + self.duplex, + self.auto_negotiation, + ) + + +def create_setting(iface_state, base_con_profile): + setting = WiredSetting(iface_state) + + nm_wired_setting = None + if base_con_profile: + nm_wired_setting = base_con_profile.get_setting_wired() + if nm_wired_setting: + nm_wired_setting = nm_wired_setting.duplicate() + + if not setting: + return nm_wired_setting + + if not nm_wired_setting: + nm_wired_setting = NM.SettingWired.new() + + if setting.mac: + nm_wired_setting.props.cloned_mac_address = setting.mac + + if setting.mtu: + nm_wired_setting.props.mtu = setting.mtu + + if setting.auto_negotiation: + nm_wired_setting.props.auto_negotiate = True + if not setting.speed and not setting.duplex: + nm_wired_setting.props.speed = 0 + nm_wired_setting.props.duplex = None + + elif not setting.speed: + ethtool_results = minimal_ethtool(str(iface_state[Interface.NAME])) + setting.speed = ethtool_results[Ethernet.SPEED] + elif not setting.duplex: + ethtool_results = minimal_ethtool(str(iface_state[Interface.NAME])) + setting.duplex = ethtool_results[Ethernet.DUPLEX] + + elif setting.auto_negotiation is False: + nm_wired_setting.props.auto_negotiate = False + ethtool_results = minimal_ethtool(str(iface_state[Interface.NAME])) + if not setting.speed: + setting.speed = ethtool_results[Ethernet.SPEED] + if not setting.duplex: + setting.duplex = ethtool_results[Ethernet.DUPLEX] + + if setting.speed: + nm_wired_setting.props.speed = setting.speed + + if setting.duplex in [Ethernet.HALF_DUPLEX, Ethernet.FULL_DUPLEX]: + nm_wired_setting.props.duplex = setting.duplex + + return nm_wired_setting + + +def get_info(device): + """ + Provides the current active values for a device + """ + info = {} + + iface = device.get_iface() + try: + info[Interface.MTU] = int(device.get_mtu()) + except AttributeError: + pass + + mac = device.get_hw_address() + if not mac: + mac = _get_mac_address_from_sysfs(iface) + + # A device may not have a MAC or it may not yet be "realized" (zeroed mac). + if mac and mac != ZEROED_MAC: + info[Interface.MAC] = mac + + if device.get_device_type() == NM.DeviceType.ETHERNET: + ethernet = _get_ethernet_info(device, iface) + if ethernet: + info[Ethernet.CONFIG_SUBTREE] = ethernet + + return info + + +def _get_mac_address_from_sysfs(ifname): + """ + Fetch the mac address of an interface from sysfs. + This is a workaround for https://bugzilla.redhat.com/1786937. + """ + mac = None + sysfs_path = f"/sys/class/net/{ifname}/address" + try: + with open(sysfs_path) as f: + mac = f.read().rstrip("\n").upper() + except FileNotFoundError: + pass + return mac + + +def _get_ethernet_info(device, iface): + ethernet = {} + try: + speed = int(device.get_speed()) + if speed > 0: + ethernet[Ethernet.SPEED] = speed + else: + return None + except AttributeError: + return None + + ethtool_results = minimal_ethtool(iface) + auto_setting = ethtool_results[Ethernet.AUTO_NEGOTIATION] + if auto_setting is True: + ethernet[Ethernet.AUTO_NEGOTIATION] = True + elif auto_setting is False: + ethernet[Ethernet.AUTO_NEGOTIATION] = False + else: + return None + + duplex_setting = ethtool_results[Ethernet.DUPLEX] + if duplex_setting in [Ethernet.HALF_DUPLEX, Ethernet.FULL_DUPLEX]: + ethernet[Ethernet.DUPLEX] = duplex_setting + else: + return None + + sriov_info = sriov.get_info(device) + if sriov_info: + ethernet.update(sriov_info) + + return ethernet diff --git a/libnmstate/nmstate.py b/libnmstate/nmstate.py new file mode 100644 index 0000000..e0249f1 --- /dev/null +++ b/libnmstate/nmstate.py @@ -0,0 +1,239 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from contextlib import contextmanager +import importlib +import logging +from operator import itemgetter +from operator import attrgetter +import os +import pkgutil + +from libnmstate import validator +from libnmstate.error import NmstateError +from libnmstate.error import NmstateValueError +from libnmstate.nm import NetworkManagerPlugin +from libnmstate.schema import DNS +from libnmstate.schema import Interface +from libnmstate.schema import Route +from libnmstate.schema import RouteRule + +from .plugin import NmstatePlugin +from .state import merge_dict + + +@contextmanager +def plugin_context(): + plugins = _load_plugins() + try: + # Lowest priority plugin should perform actions first. + plugins.sort(key=attrgetter("priority")) + yield plugins + except (Exception, KeyboardInterrupt): + for plugin in plugins: + if plugin.checkpoint: + try: + plugin.rollback_checkpoint() + # Don't complex thing by raise exception when handling another + # exception, just log the rollback failure. + except Exception as e: + logging.error(f"Rollback failed with error {e}") + raise + finally: + for plugin in plugins: + plugin.unload() + + +def show_with_plugins(plugins, include_status_data=None): + for plugin in plugins: + plugin.refresh_content() + report = {} + if include_status_data: + report["capabilities"] = plugins_capabilities(plugins) + + report[Interface.KEY] = _get_interface_info_from_plugins(plugins) + + route_plugin = _find_plugin_for_capability( + plugins, NmstatePlugin.PLUGIN_CAPABILITY_ROUTE + ) + if route_plugin: + report[Route.KEY] = route_plugin.get_routes() + + route_rule_plugin = _find_plugin_for_capability( + plugins, NmstatePlugin.PLUGIN_CAPABILITY_ROUTE_RULE + ) + if route_rule_plugin: + report[RouteRule.KEY] = route_rule_plugin.get_route_rules() + + dns_plugin = _find_plugin_for_capability( + plugins, NmstatePlugin.PLUGIN_CAPABILITY_DNS + ) + if dns_plugin: + report[DNS.KEY] = dns_plugin.get_dns_client_config() + + validator.schema_validate(report) + return report + + +def plugins_capabilities(plugins): + capabilities = set() + for plugin in plugins: + capabilities.update(set(plugin.capabilities)) + return list(capabilities) + + +def _load_plugins(): + plugins = [NetworkManagerPlugin()] + plugins.extend(_load_external_py_plugins()) + return plugins + + +def _load_external_py_plugins(): + """ + Load module from folder defined in system evironment NMSTATE_PLUGIN_DIR, + if empty, use the 'plugins' folder of current python file. + """ + plugins = [] + plugin_dir = os.environ.get("NMSTATE_PLUGIN_DIR") + if not plugin_dir: + plugin_dir = f"{os.path.dirname(os.path.realpath(__file__))}/plugins" + + for _, name, ispkg in pkgutil.iter_modules([plugin_dir]): + if name.startswith("nmstate_plugin_"): + try: + spec = importlib.util.spec_from_file_location( + name, f"{plugin_dir}/{name}.py" + ) + plugin_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(plugin_module) + plugin = plugin_module.NMSTATE_PLUGIN() + plugins.append(plugin) + except Exception as error: + logging.warning(f"Failed to load plugin {name}: {error}") + + return plugins + + +def _find_plugin_for_capability(plugins, capability): + """ + Return the plugin with specified capability and highest priority. + """ + chose_plugin = None + for plugin in plugins: + if ( + chose_plugin + and capability in plugin.plugin_capabilities + and plugin.priority > chose_plugin.priority + ) or not chose_plugin: + chose_plugin = plugin + return chose_plugin + + +def _get_interface_info_from_plugins(plugins): + all_ifaces = {} + IFACE_PRIORITY_METADATA = "_plugin_priority" + for plugin in plugins: + if ( + NmstatePlugin.PLUGIN_CAPABILITY_IFACE + not in plugin.plugin_capabilities + ): + continue + for iface in plugin.get_interfaces(): + iface[IFACE_PRIORITY_METADATA] = plugin.priority + iface_name = iface[Interface.NAME] + if iface_name in all_ifaces: + existing_iface = all_ifaces[iface_name] + existing_priority = existing_iface[IFACE_PRIORITY_METADATA] + current_priority = plugin.priority + if current_priority > existing_priority: + merge_dict(iface, existing_iface) + all_ifaces[iface_name] = iface + else: + merge_dict(existing_iface, iface) + else: + all_ifaces[iface_name] = iface + + # Remove metadata + for iface in all_ifaces.values(): + iface.pop(IFACE_PRIORITY_METADATA) + + return sorted(all_ifaces.values(), key=itemgetter(Interface.NAME)) + + +def create_checkpoints(plugins, timeout): + """ + Return a string containing all the check point created by each plugin in + the format: + plugin.name||plugin.name|. +# + +from abc import ABCMeta +from abc import abstractproperty +from abc import abstractmethod + +from .error import NmstatePluginError + + +class NmstatePlugin(metaclass=ABCMeta): + OVS_CAPABILITY = "openvswitch" + TEAM_CAPABILITY = "team" + + PLUGIN_CAPABILITY_IFACE = "interface" + PLUGIN_CAPABILITY_ROUTE = "route" + PLUGIN_CAPABILITY_ROUTE_RULE = "route_rule" + PLUGIN_CAPABILITY_DNS = "dns" + + DEFAULT_PRIORITY = 10 + + def unload(self): + pass + + @property + def checkpoint(self): + return None + + def refresh_content(self): + pass + + @abstractproperty + def name(self): + pass + + @property + def priority(self): + return NmstatePlugin.DEFAULT_PRIORITY + + def get_interfaces(self): + raise NmstatePluginError( + f"Plugin {self.name} BUG: get_interfaces() not implemented" + ) + + def apply_changes(self, net_state, save_to_disk): + pass + + @property + def capabilities(self): + return [] + + @abstractmethod + def plugin_capabilities(self): + pass + + def create_checkpoint(self, timeout): + return None + + def rollback_checkpoint(self, checkpoint=None): + pass + + def destroy_checkpoint(self, checkpoint=None): + pass + + def get_routes(self): + raise NmstatePluginError( + f"Plugin {self.name} BUG: get_routes() not implemented" + ) + + def get_route_rules(self): + raise NmstatePluginError( + f"Plugin {self.name} BUG: get_route_rules() not implemented" + ) + + def get_dns_client_config(self): + raise NmstatePluginError( + f"Plugin {self.name} BUG: get_dns_client_config() not implemented" + ) diff --git a/libnmstate/plugins/__init__.py b/libnmstate/plugins/__init__.py new file mode 100644 index 0000000..cb856a8 --- /dev/null +++ b/libnmstate/plugins/__init__.py @@ -0,0 +1,18 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# diff --git a/libnmstate/plugins/nmstate_plugin_ovsdb.py b/libnmstate/plugins/nmstate_plugin_ovsdb.py new file mode 100644 index 0000000..83965e1 --- /dev/null +++ b/libnmstate/plugins/nmstate_plugin_ovsdb.py @@ -0,0 +1,279 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import os +import time + +import ovs +from ovs.db.idl import Transaction, Idl, SchemaHelper + +from libnmstate.plugin import NmstatePlugin +from libnmstate.schema import Interface +from libnmstate.schema import OVSInterface +from libnmstate.schema import OVSBridge +from libnmstate.schema import OvsDB +from libnmstate.error import NmstateNotImplementedError +from libnmstate.error import NmstateTimeoutError +from libnmstate.error import NmstatePermissionError +from libnmstate.error import NmstateValueError +from libnmstate.error import NmstatePluginError + +TIMEOUT = 5 + +DEFAULT_OVS_DB_SOCKET_PATH = "/run/openvswitch/db.sock" +DEFAULT_OVS_SCHEMA_PATH = "/usr/share/openvswitch/vswitch.ovsschema" + +NM_EXTERNAL_ID = "NM.connection.uuid" + + +class _Changes: + def __init__(self, table_name, column_name, row_name, column_value): + self.table_name = table_name + self.column_name = column_name + self.row_name = row_name + self.column_value = column_value + + def __str__(self): + return f"{self.__dict__}" + + +class NmstateOvsdbPlugin(NmstatePlugin): + def __init__(self): + self._schema = None + self._idl = None + self._transaction = None + self._seq_no = 0 + self._load_schema() + self._connect_to_ovs_db() + + def unload(self): + if self._transaction: + self._transaction.abort() + self._transaction = None + if self._idl: + self._idl.close() + self._idl = None + + def _load_schema(self): + schema_path = os.environ.get( + "OVS_SCHEMA_PATH", DEFAULT_OVS_SCHEMA_PATH + ) + if not os.path.exists(schema_path): + raise NmstateValueError( + f"OVS schema file {schema_path} does not exist, " + "please define the correct one via " + "environment variable 'OVS_SCHEMA_PATH'" + ) + if not os.access(schema_path, os.R_OK): + raise NmstatePermissionError( + f"Has no read permission to OVS schema file {schema_path}" + ) + self._schema = SchemaHelper(schema_path) + self._schema.register_columns( + "Interface", [OvsDB.EXTERNAL_IDS, "name"] + ) + self._schema.register_columns("Bridge", [OvsDB.EXTERNAL_IDS, "name"]) + + def _connect_to_ovs_db(self): + socket_path = os.environ.get( + "OVS_DB_UNIX_SOCKET_PATH", DEFAULT_OVS_DB_SOCKET_PATH + ) + if not os.path.exists(socket_path): + raise NmstateValueError( + f"OVS database socket file {socket_path} does not exist, " + "please start the OVS daemon or define the socket path via " + "environment variable 'OVS_DB_UNIX_SOCKET_PATH'" + ) + if not os.access(socket_path, os.R_OK): + raise NmstatePermissionError( + f"Has no read permission to OVS db socket file {socket_path}" + ) + + self._idl = Idl(f"unix:{socket_path}", self._schema) + self.refresh_content() + if not self._idl.has_ever_connected(): + self._idl = None + raise NmstatePluginError("Failed to connect to OVS DB") + + def refresh_content(self): + if self._idl: + timeout_end = time.time() + TIMEOUT + self._idl.run() + if self._idl.change_seqno == self._seq_no and self._seq_no: + return + while True: + changed = self._idl.run() + cur_seq_no = self._idl.change_seqno + if cur_seq_no != self._seq_no or changed: + self._seq_no = cur_seq_no + return + poller = ovs.poller.Poller() + self._idl.wait(poller) + poller.timer_wait(TIMEOUT * 1000) + poller.block() + if time.time() > timeout_end: + raise NmstateTimeoutError( + f"Plugin {self.name} timeout({TIMEOUT} " + "seconds) when refresh OVS database connection" + ) + + @property + def name(self): + return "nmstate-plugin-ovsdb" + + @property + def priority(self): + return NmstatePlugin.DEFAULT_PRIORITY + 1 + + @property + def plugin_capabilities(self): + return NmstatePlugin.PLUGIN_CAPABILITY_IFACE + + def get_interfaces(self): + ifaces = [] + for row in list(self._idl.tables["Interface"].rows.values()) + list( + self._idl.tables["Bridge"].rows.values() + ): + ifaces.append( + { + Interface.NAME: row.name, + OvsDB.OVS_DB_SUBTREE: { + OvsDB.EXTERNAL_IDS: row.external_ids + }, + } + ) + return ifaces + + def apply_changes(self, net_state, save_to_disk): + self.refresh_content() + pending_changes = [] + for iface in net_state.ifaces.values(): + if not iface.is_changed and not iface.is_desired: + continue + if not iface.is_up: + continue + if iface.type == OVSBridge.TYPE: + table_name = "Bridge" + elif iface.type == OVSInterface.TYPE: + table_name = "Interface" + else: + continue + pending_changes.extend(_generate_db_change(table_name, iface)) + if pending_changes: + if not save_to_disk: + raise NmstateNotImplementedError( + "ovsdb plugin does not support memory only changes" + ) + elif self._idl: + self._start_transaction() + self._db_write(pending_changes) + self._commit_transaction() + + def _db_write(self, changes): + changes_index = {change.row_name: change for change in changes} + changed_tables = set(change.table_name for change in changes) + updated_names = [] + for changed_table in changed_tables: + for row in self._idl.tables[changed_table].rows.values(): + if row.name in changes_index: + change = changes_index[row.name] + setattr(row, change.column_name, change.column_value) + updated_names.append(change.row_name) + new_rows = set(changes_index.keys()) - set(updated_names) + if new_rows: + raise NmstatePluginError( + f"BUG: row {new_rows} does not exists in OVS DB " + "and currently we don't create new row" + ) + + def _start_transaction(self): + self._transaction = Transaction(self._idl) + + def _commit_transaction(self): + if self._transaction: + status = self._transaction.commit() + timeout_end = time.time() + TIMEOUT + while status == Transaction.INCOMPLETE: + self._idl.run() + poller = ovs.poller.Poller() + self._idl.wait(poller) + self._transaction.wait(poller) + poller.timer_wait(TIMEOUT * 1000) + poller.block() + if time.time() > timeout_end: + raise NmstateTimeoutError( + f"Plugin {self.name} timeout({TIMEOUT} " + "seconds) when commit OVS database transaction" + ) + status = self._transaction.commit() + + if status == Transaction.SUCCESS: + self.refresh_content() + + transaction_error = self._transaction.get_error() + self._transaction = None + + if status not in (Transaction.SUCCESS, Transaction.UNCHANGED): + raise NmstatePluginError( + f"Plugin {self.name} failure on commiting OVS database " + f"transaction: status: {status} " + f"error: {transaction_error}" + ) + else: + raise NmstatePluginError( + "BUG: _commit_transaction() invoked with " + "self._transaction is None" + ) + + +def _generate_db_change(table_name, iface_state): + return _generate_db_change_external_ids(table_name, iface_state) + + +def _generate_db_change_external_ids(table_name, iface_state): + pending_changes = [] + desire_ids = iface_state.original_dict.get(OvsDB.OVS_DB_SUBTREE, {}).get( + OvsDB.EXTERNAL_IDS + ) + if desire_ids and not isinstance(desire_ids, dict): + raise NmstateValueError("Invalid external_ids, should be dictionary") + + if desire_ids or desire_ids == {}: + # should include external_id required by NetworkManager. + merged_ids = ( + iface_state.to_dict() + .get(OvsDB.OVS_DB_SUBTREE, {}) + .get(OvsDB.EXTERNAL_IDS, {}) + ) + if NM_EXTERNAL_ID in merged_ids: + desire_ids[NM_EXTERNAL_ID] = merged_ids[NM_EXTERNAL_ID] + + # Convert all value to string + for key, value in desire_ids.items(): + desire_ids[key] = str(value) + + pending_changes.append( + _Changes( + table_name, OvsDB.EXTERNAL_IDS, iface_state.name, desire_ids + ) + ) + return pending_changes + + +NMSTATE_PLUGIN = NmstateOvsdbPlugin diff --git a/libnmstate/prettystate.py b/libnmstate/prettystate.py new file mode 100644 index 0000000..10e22d6 --- /dev/null +++ b/libnmstate/prettystate.py @@ -0,0 +1,133 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from collections.abc import Mapping +from collections.abc import Sequence +from copy import deepcopy +import difflib +import json + +import yaml + +from .schema import DNS +from .schema import Route +from .schema import RouteRule +from .schema import Interface + +PRIORITY_LIST = ( + "name", + "type", + "state", + "enabled", + DNS.KEY, + RouteRule.KEY, + Route.KEY, + Interface.KEY, +) + + +def format_desired_current_state_diff(desired_state, current_state): + pretty_desired_state = PrettyState(desired_state).yaml + pretty_current_state = PrettyState(current_state).yaml + + diff = "".join( + difflib.unified_diff( + pretty_desired_state.splitlines(True), + pretty_current_state.splitlines(True), + fromfile="desired", + tofile="current", + n=3, + ) + ) + return ( + "\n" + "desired\n" + "=======\n" + "{}\n" + "current\n" + "=======\n" + "{}\n" + "difference\n" + "==========\n" + "{}\n".format(pretty_desired_state, pretty_current_state, diff) + ) + + +class PrettyState: + def __init__(self, state): + yaml.add_representer(dict, represent_dict) + self.state = _sort_with_priority(state) + + @property + def yaml(self): + return yaml.dump( + self.state, default_flow_style=False, explicit_start=True + ) + + @property + def json(self): + return json.dumps(self.state, indent=4, separators=(",", ": ")) + + +def represent_dict(dumper, data): + """ + Represent dictionary with insert order + """ + value = [] + + for item_key, item_value in data.items(): + node_key = dumper.represent_data(item_key) + node_value = dumper.represent_data(item_value) + value.append((node_key, node_value)) + + return yaml.nodes.MappingNode("tag:yaml.org,2002:map", value) + + +def represent_unicode(_, data): + """ + Represent unicode as regular string + + Source: + https://stackoverflow.com/questions/1950306/pyyaml-dumping-without-tags + + """ + + return yaml.ScalarNode( + tag="tag:yaml.org,2002:str", value=data.encode("utf-8") + ) + + +def _sort_with_priority(data): + if isinstance(data, Sequence) and not isinstance(data, str): + return [_sort_with_priority(item) for item in data] + elif isinstance(data, Mapping): + new_data = {} + for key in sorted(data.keys(), key=_sort_with_priority_key_func): + new_data[key] = _sort_with_priority(data[key]) + return new_data + else: + return deepcopy(data) + + +def _sort_with_priority_key_func(key): + try: + priority = PRIORITY_LIST.index(key) + except ValueError: + priority = len(PRIORITY_LIST) + return (priority, key) diff --git a/libnmstate/route.py b/libnmstate/route.py new file mode 100644 index 0000000..a182f99 --- /dev/null +++ b/libnmstate/route.py @@ -0,0 +1,258 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from collections import defaultdict + +from libnmstate.error import NmstateValueError +from libnmstate.error import NmstateVerificationError +from libnmstate.iplib import is_ipv6_address +from libnmstate.iplib import canonicalize_ip_network +from libnmstate.iplib import canonicalize_ip_address +from libnmstate.prettystate import format_desired_current_state_diff +from libnmstate.schema import Interface +from libnmstate.schema import Route + +from .state import StateEntry +from .state import state_match + + +class RouteEntry(StateEntry): + IPV4_DEFAULT_GATEWAY_DESTINATION = "0.0.0.0/0" + IPV6_DEFAULT_GATEWAY_DESTINATION = "::/0" + + def __init__(self, route): + self.table_id = route.get(Route.TABLE_ID) + self.state = route.get(Route.STATE) + self.metric = route.get(Route.METRIC) + self.destination = route.get(Route.DESTINATION) + self.next_hop_address = route.get(Route.NEXT_HOP_ADDRESS) + self.next_hop_interface = route.get(Route.NEXT_HOP_INTERFACE) + # TODO: Convert IPv6 full address to abbreviated address + self.complement_defaults() + self._invalid_reason = None + self._canonicalize_ip_address() + + @property + def is_ipv6(self): + return is_ipv6_address(self.destination) + + @property + def is_gateway(self): + if self.is_ipv6: + return ( + self.destination == RouteEntry.IPV6_DEFAULT_GATEWAY_DESTINATION + ) + else: + return ( + self.destination == RouteEntry.IPV4_DEFAULT_GATEWAY_DESTINATION + ) + + @property + def invalid_reason(self): + return self._invalid_reason + + def complement_defaults(self): + if not self.absent: + if self.table_id is None: + self.table_id = Route.USE_DEFAULT_ROUTE_TABLE + if self.metric is None: + self.metric = Route.USE_DEFAULT_METRIC + if self.next_hop_address is None: + self.next_hop_address = "" + + def _keys(self): + return ( + self.table_id, + self.metric, + self.destination, + self.next_hop_address, + self.next_hop_interface, + ) + + def __lt__(self, other): + return ( + self.table_id or Route.USE_DEFAULT_ROUTE_TABLE, + self.next_hop_interface or "", + self.destination or "", + ) < ( + other.table_id or Route.USE_DEFAULT_ROUTE_TABLE, + other.next_hop_interface or "", + other.destination or "", + ) + + @property + def absent(self): + return self.state == Route.STATE_ABSENT + + def is_valid(self, ifaces): + """ + Return False when next hop interface or destination not defined; + Return False when route is next hop to any of these interfaces: + * Interface not in InterfaceState.UP state. + * Interface does not exists. + * Interface has IPv4/IPv6 disabled. + * Interface configured as dynamic IPv4/IPv6. + """ + if not self.next_hop_interface: + self._invalid_reason = ( + "Route entry does not have next hop interface" + ) + return False + if not self.destination: + self._invalid_reason = "Route entry does not have destination" + return False + iface = ifaces.get(self.next_hop_interface) + if not iface: + self._invalid_reason = ( + f"Route {self.to_dict()} next hop to unknown interface" + ) + return False + if not iface.is_up: + self._invalid_reason = ( + f"Route {self.to_dict()} next hop to down/absent interface" + ) + return False + if iface.is_dynamic( + Interface.IPV6 if self.is_ipv6 else Interface.IPV4 + ): + self._invalid_reason = ( + f"Route {self.to_dict()} next hop to interface with dynamic IP" + ) + return False + if self.is_ipv6: + if not iface.is_ipv6_enabled(): + self._invalid_reason = ( + f"Route {self.to_dict()} next hop to interface with IPv6 " + "disabled" + ) + return False + else: + if not iface.is_ipv4_enabled(): + self._invalid_reason = ( + f"Route {self.to_dict()} next hop to interface with IPv4 " + "disabled" + ) + return False + return True + + def _canonicalize_ip_address(self): + if not self.absent: + if self.destination: + self.destination = canonicalize_ip_network(self.destination) + if self.next_hop_address: + self.next_hop_address = canonicalize_ip_address( + self.next_hop_address + ) + + +class RouteState: + def __init__(self, ifaces, des_route_state, cur_route_state): + self._cur_routes = defaultdict(set) + self._routes = defaultdict(set) + if cur_route_state: + for entry in cur_route_state.get(Route.CONFIG, []): + rt = RouteEntry(entry) + self._cur_routes[rt.next_hop_interface].add(rt) + if not ifaces or rt.is_valid(ifaces): + self._routes[rt.next_hop_interface].add(rt) + if des_route_state: + self._merge_routes(des_route_state, ifaces) + + def _merge_routes(self, des_route_state, ifaces): + # Handle absent route before adding desired route entries to + # make sure absent route does not delete route defined in + # desire state + for entry in des_route_state.get(Route.CONFIG, []): + rt = RouteEntry(entry) + if rt.absent: + self._apply_absent_routes(rt, ifaces) + for entry in des_route_state.get(Route.CONFIG, []): + rt = RouteEntry(entry) + if not rt.absent: + if rt.is_valid(ifaces): + ifaces[rt.next_hop_interface].mark_as_changed() + self._routes[rt.next_hop_interface].add(rt) + else: + raise NmstateValueError(rt.invalid_reason) + + def _apply_absent_routes(self, rt, ifaces): + """ + Remove routes based on absent routes and treat missing property as + wildcard match. + """ + absent_iface_name = rt.next_hop_interface + for iface_name, route_set in self._routes.items(): + if absent_iface_name and absent_iface_name != iface_name: + continue + new_routes = set() + for route in route_set: + if not rt.match(route): + new_routes.add(route) + if new_routes != route_set: + ifaces[iface_name].mark_as_changed() + self._routes[iface_name] = new_routes + + def gen_metadata(self, ifaces): + """ + Generate metada which could used for storing into interface. + Data structure returned is: + { + iface_name: { + Interface.IPV4: ipv4_routes, + Interface.IPV6: ipv6_routes, + } + } + """ + route_metadata = {} + for iface_name, route_set in self._routes.items(): + route_metadata[iface_name] = { + Interface.IPV4: [], + Interface.IPV6: [], + } + for route in route_set: + family = Interface.IPV6 if route.is_ipv6 else Interface.IPV4 + route_metadata[iface_name][family].append(route.to_dict()) + return route_metadata + + @property + def config_iface_routes(self): + """ + Return configured routes indexed by next hop interface + """ + if list(self._routes.values()) == [set()]: + return {} + return self._routes + + def verify(self, cur_route_state): + current = RouteState( + ifaces=None, des_route_state=None, cur_route_state=cur_route_state + ) + for iface_name, route_set in self._routes.items(): + routes_info = [r.to_dict() for r in sorted(route_set)] + cur_routes_info = [ + r.to_dict() + for r in sorted(current._routes.get(iface_name, set())) + ] + if not state_match(routes_info, cur_routes_info): + raise NmstateVerificationError( + format_desired_current_state_diff( + {Route.KEY: {Route.CONFIG: routes_info}}, + {Route.KEY: {Route.CONFIG: cur_routes_info}}, + ) + ) diff --git a/libnmstate/route_rule.py b/libnmstate/route_rule.py new file mode 100644 index 0000000..f35d59c --- /dev/null +++ b/libnmstate/route_rule.py @@ -0,0 +1,184 @@ +from collections import defaultdict +import logging + +from libnmstate.error import NmstateNotImplementedError +from libnmstate.error import NmstateVerificationError +from libnmstate.error import NmstateValueError +from libnmstate.iplib import KERNEL_MAIN_ROUTE_TABLE_ID +from libnmstate.iplib import is_ipv6_address +from libnmstate.iplib import canonicalize_ip_network +from libnmstate.prettystate import format_desired_current_state_diff +from libnmstate.schema import Interface +from libnmstate.schema import RouteRule +from libnmstate.schema import Route + +from .state import StateEntry +from .state import state_match + + +class RouteRuleEntry(StateEntry): + def __init__(self, route_rule): + self.ip_from = route_rule.get(RouteRule.IP_FROM) + self.ip_to = route_rule.get(RouteRule.IP_TO) + self.priority = route_rule.get(RouteRule.PRIORITY) + self.route_table = route_rule.get(RouteRule.ROUTE_TABLE) + self._complement_defaults() + self._canonicalize_ip_network() + + def _complement_defaults(self): + if self.ip_from is None: + self.ip_from = "" + if self.ip_to is None: + self.ip_to = "" + if self.priority is None: + self.priority = RouteRule.USE_DEFAULT_PRIORITY + if ( + self.route_table is None + or self.route_table == RouteRule.USE_DEFAULT_ROUTE_TABLE + ): + self.route_table = KERNEL_MAIN_ROUTE_TABLE_ID + + def _canonicalize_ip_network(self): + if self.ip_from: + self.ip_from = canonicalize_ip_network(self.ip_from) + if self.ip_to: + self.ip_to = canonicalize_ip_network(self.ip_to) + + def _keys(self): + return (self.ip_from, self.ip_to, self.priority, self.route_table) + + @property + def is_ipv6(self): + if self.ip_from: + return is_ipv6_address(self.ip_from) + elif self.ip_to: + return is_ipv6_address(self.ip_to) + else: + logging.warning( + f"Neither {RouteRule.IP_FROM} nor {RouteRule.IP_TO} " + "is defined, treating it a IPv4 route rule" + ) + return False + + @property + def absent(self): + raise NmstateNotImplementedError( + "RouteRuleEntry does not support absent property" + ) + + def is_valid(self, config_iface_routes): + """ + Return False when there is no route for defined route table. + """ + found = False + for route_set in config_iface_routes.values(): + for route in route_set: + if route.table_id == self.route_table or ( + route.table_id == Route.USE_DEFAULT_ROUTE_TABLE + and self.route_table == KERNEL_MAIN_ROUTE_TABLE_ID + ): + found = True + break + return found + + +class RouteRuleState: + def __init__(self, route_state, des_rule_state, cur_rule_state): + self._config_changed = False + self._cur_rules = defaultdict(set) + self._rules = defaultdict(set) + if cur_rule_state: + for rule_dict in _get_config(cur_rule_state): + rule = RouteRuleEntry(rule_dict) + self._cur_rules[rule.route_table].add(rule) + if des_rule_state: + for rule_dict in _get_config(des_rule_state): + rule = RouteRuleEntry(rule_dict) + self._rules[rule.route_table].add(rule) + if self._rules != self._cur_rules: + self._config_changed = True + else: + # Discard invalid route rule when merging from current + for rules in self._cur_rules.values(): + for rule in rules: + if not route_state or rule.is_valid( + route_state.config_iface_routes + ): + self._rules[rule.route_table].add(rule) + + @property + def _config(self): + return _get_config(self._rules) + + def verify(self, cur_rule_state): + current = RouteRuleState( + route_state=None, + des_rule_state=None, + cur_rule_state=cur_rule_state, + ) + for route_table, rules in self._rules.items(): + rule_info = [ + _remove_route_rule_default_values(r.to_dict()) + for r in sorted(rules) + ] + cur_rule_info = [ + r.to_dict() + for r in sorted(current._rules.get(route_table, set())) + ] + + if not state_match(rule_info, cur_rule_info): + raise NmstateVerificationError( + format_desired_current_state_diff( + {RouteRule.KEY: {RouteRule.CONFIG: rule_info}}, + {RouteRule.KEY: {RouteRule.CONFIG: cur_rule_info}}, + ) + ) + + @property + def config_changed(self): + return self._config_changed + + def gen_metadata(self, route_state): + """ + Generate metada which could used for storing into interface. + Data structure returned is: + { + iface_name: { + Interface.IPV4: ipv4_route_rules, + Interface.IPV6: ipv6_route_rules, + } + } + """ + route_rule_metadata = {} + for route_table, rules in self._rules.items(): + iface_name = self._iface_for_route_table(route_state, route_table) + route_rule_metadata[iface_name] = { + Interface.IPV4: [], + Interface.IPV6: [], + } + for rule in rules: + family = Interface.IPV6 if rule.is_ipv6 else Interface.IPV4 + route_rule_metadata[iface_name][family].append(rule.to_dict()) + return route_rule_metadata + + def _iface_for_route_table(self, route_state, route_table): + for routes in route_state.config_iface_routes.values(): + for route in routes: + if route.table_id == route_table: + return route.next_hop_interface + raise NmstateValueError( + "Failed to find interface to with route table ID " + f"{route_table} to store route rules" + ) + + +def _get_config(state): + return state.get(RouteRule.CONFIG, []) + + +def _remove_route_rule_default_values(rule): + if rule.get(RouteRule.PRIORITY) == RouteRule.USE_DEFAULT_PRIORITY: + del rule[RouteRule.PRIORITY] + if rule.get(RouteRule.ROUTE_TABLE) == RouteRule.USE_DEFAULT_ROUTE_TABLE: + del rule[RouteRule.ROUTE_TABLE] + return rule diff --git a/libnmstate/schema.py b/libnmstate/schema.py new file mode 100644 index 0000000..8a86c48 --- /dev/null +++ b/libnmstate/schema.py @@ -0,0 +1,349 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import pkgutil +import yaml + + +def load(schema_name): + return yaml.load( + pkgutil.get_data("libnmstate", "schemas/" + schema_name + ".yaml"), + Loader=yaml.SafeLoader, + ) + + +ifaces_schema = load("operational-state") + + +class Interface: + KEY = "interfaces" + + NAME = "name" + TYPE = "type" + STATE = "state" + DESCRIPTION = "description" + + IPV4 = "ipv4" + IPV6 = "ipv6" + + MAC = "mac-address" + MTU = "mtu" + + +class Route: + KEY = "routes" + + RUNNING = "running" + CONFIG = "config" + STATE = "state" + STATE_ABSENT = "absent" + TABLE_ID = "table-id" + DESTINATION = "destination" + NEXT_HOP_INTERFACE = "next-hop-interface" + NEXT_HOP_ADDRESS = "next-hop-address" + METRIC = "metric" + USE_DEFAULT_METRIC = -1 + USE_DEFAULT_ROUTE_TABLE = 0 + + +class RouteRule: + KEY = "route-rules" + CONFIG = "config" + IP_FROM = "ip-from" + IP_TO = "ip-to" + PRIORITY = "priority" + ROUTE_TABLE = "route-table" + USE_DEFAULT_PRIORITY = -1 + USE_DEFAULT_ROUTE_TABLE = 0 + + +class DNS: + KEY = "dns-resolver" + RUNNING = "running" + CONFIG = "config" + SERVER = "server" + SEARCH = "search" + + +class Constants: + INTERFACES = Interface.KEY + ROUTES = Route.KEY + DNS = DNS.KEY + + +class InterfaceState: + KEY = Interface.STATE + + DOWN = "down" + UP = "up" + ABSENT = "absent" + + +class InterfaceType: + KEY = Interface.TYPE + + BOND = "bond" + DUMMY = "dummy" + ETHERNET = "ethernet" + LINUX_BRIDGE = "linux-bridge" + OVS_BRIDGE = "ovs-bridge" + OVS_INTERFACE = "ovs-interface" + OVS_PORT = "ovs-port" + UNKNOWN = "unknown" + VLAN = "vlan" + VXLAN = "vxlan" + TEAM = "team" + OTHER = "other" + + VIRT_TYPES = ( + BOND, + DUMMY, + LINUX_BRIDGE, + OVS_BRIDGE, + OVS_PORT, + OVS_INTERFACE, + TEAM, + VLAN, + VXLAN, + ) + + +class InterfaceIP: + ENABLED = "enabled" + ADDRESS = "address" + ADDRESS_IP = "ip" + ADDRESS_PREFIX_LENGTH = "prefix-length" + DHCP = "dhcp" + AUTO_DNS = "auto-dns" + AUTO_GATEWAY = "auto-gateway" + AUTO_ROUTES = "auto-routes" + + +class InterfaceIPv4(InterfaceIP): + pass + + +class InterfaceIPv6(InterfaceIP): + AUTOCONF = "autoconf" + + +class Bond: + KEY = InterfaceType.BOND + CONFIG_SUBTREE = "link-aggregation" + + MODE = "mode" + SLAVES = "slaves" + OPTIONS_SUBTREE = "options" + + +class BondMode: + ROUND_ROBIN = "balance-rr" + ACTIVE_BACKUP = "active-backup" + XOR = "balance-xor" + BROADCAST = "broadcast" + LACP = "802.3ad" + TLB = "balance-tlb" + ALB = "balance-alb" + + +class Bridge: + CONFIG_SUBTREE = "bridge" + OPTIONS_SUBTREE = "options" + PORT_SUBTREE = "port" + + class Port: + NAME = "name" + VLAN_SUBTREE = "vlan" + + class Vlan: + ENABLE_NATIVE = "enable-native" + TRUNK_TAGS = "trunk-tags" + MODE = "mode" + TAG = "tag" + + class Mode: + ACCESS = "access" + TRUNK = "trunk" + UNKNOWN = "unknown" + + class TrunkTags: + ID = "id" + ID_RANGE = "id-range" + MIN_RANGE = "min" + MAX_RANGE = "max" + + +class LinuxBridge(Bridge): + TYPE = "linux-bridge" + STP_SUBTREE = "stp" + MULTICAST_SUBTREE = "multicast" + + class Options: + GROUP_FORWARD_MASK = "group-forward-mask" + MAC_AGEING_TIME = "mac-ageing-time" + MULTICAST_SNOOPING = "multicast-snooping" + GROUP_ADDR = "group-addr" + GROUP_FWD_MASK = "group-fwd-mask" + HASH_ELASTICITY = "hash-elasticity" + HASH_MAX = "hash-max" + MULTICAST_ROUTER = "multicast-router" + MULTICAST_LAST_MEMBER_COUNT = "multicast-last-member-count" + MULTICAST_LAST_MEMBER_INTERVAL = "multicast-last-member-interval" + MULTICAST_MEMBERSHIP_INTERVAL = "multicast-membership-interval" + MULTICAST_QUERIER = "multicast-querier" + MULTICAST_QUERIER_INTERVAL = "multicast-querier-interval" + MULTICAST_QUERY_USE_IFADDR = "multicast-query-use-ifaddr" + MULTICAST_QUERY_INTERVAL = "multicast-query-interval" + MULTICAST_QUERY_RESPONSE_INTERVAL = "multicast-query-response-interval" + MULTICAST_STARTUP_QUERY_COUNT = "multicast-startup-query-count" + MULTICAST_STARTUP_QUERY_INTERVAL = "multicast-startup-query-interval" + + # Read only properties begin + HELLO_TIMER = "hello-timer" + GC_TIMER = "gc-timer" + # Read only properties end + + class Port(Bridge.Port): + STP_HAIRPIN_MODE = "stp-hairpin-mode" + STP_PATH_COST = "stp-path-cost" + STP_PRIORITY = "stp-priority" + + class STP: + ENABLED = "enabled" + FORWARD_DELAY = "forward-delay" + HELLO_TIME = "hello-time" + MAX_AGE = "max-age" + PRIORITY = "priority" + + +class Ethernet: + TYPE = InterfaceType.ETHERNET + CONFIG_SUBTREE = "ethernet" + + AUTO_NEGOTIATION = "auto-negotiation" + SPEED = "speed" + DUPLEX = "duplex" + + FULL_DUPLEX = "full" + HALF_DUPLEX = "half" + + SRIOV_SUBTREE = "sr-iov" + + class SRIOV: + TOTAL_VFS = "total-vfs" + VFS_SUBTREE = "vfs" + + class VFS: + ID = "id" + MAC_ADDRESS = "mac-address" + SPOOF_CHECK = "spoof-check" + TRUST = "trust" + MIN_TX_RATE = "min-tx-rate" + MAX_TX_RATE = "max-tx-rate" + + +class VLAN: + TYPE = InterfaceType.VLAN + CONFIG_SUBTREE = "vlan" + + ID = "id" + BASE_IFACE = "base-iface" + + +class VXLAN: + TYPE = InterfaceType.VXLAN + CONFIG_SUBTREE = "vxlan" + + ID = "id" + BASE_IFACE = "base-iface" + REMOTE = "remote" + DESTINATION_PORT = "destination-port" + + +class OvsDB: + OVS_DB_SUBTREE = "ovs-db" + # Don't use hypen as this is OVS data base entry + EXTERNAL_IDS = "external_ids" + + +class OVSInterface(OvsDB): + TYPE = InterfaceType.OVS_INTERFACE + PATCH_CONFIG_SUBTREE = "patch" + + class Patch: + PEER = "peer" + + +class OVSBridge(Bridge, OvsDB): + TYPE = "ovs-bridge" + + class Options: + FAIL_MODE = "fail-mode" + MCAST_SNOOPING_ENABLED = "mcast-snooping-enable" + RSTP = "rstp" + STP = "stp" + + class Port(Bridge.Port): + LINK_AGGREGATION_SUBTREE = "link-aggregation" + + class LinkAggregation: + MODE = "mode" + SLAVES_SUBTREE = "slaves" + + class Slave: + NAME = "name" + + class Options: + DOWN_DELAY = "bond-downdelay" + UP_DELAY = "bond-updelay" + + class Mode: + ACTIVE_BACKUP = "active-backup" + BALANCE_SLB = "balance-slb" + BALANCE_TCP = "balance-tcp" + LACP = "lacp" + + +class Team: + TYPE = InterfaceType.TEAM + CONFIG_SUBTREE = InterfaceType.TEAM + + PORT_SUBTREE = "ports" + RUNNER_SUBTREE = "runner" + + class Port: + NAME = "name" + + class Runner: + NAME = "name" + + class RunnerMode: + LOAD_BALANCE = "loadbalance" + + +class LLDP: + CONFIG_SUBTREE = "lldp" + ENABLED = "enabled" + NEIGHBORS_SUBTREE = "neighbors" + + class Neighbors: + DESCRIPTION = "_description" + TLV_TYPE = "type" + TLV_SUBTYPE = "subtype" + ORGANIZATION_CODE = "oui" diff --git a/libnmstate/schemas/operational-state.yaml b/libnmstate/schemas/operational-state.yaml new file mode 100644 index 0000000..d856aa5 --- /dev/null +++ b/libnmstate/schemas/operational-state.yaml @@ -0,0 +1,677 @@ +$schema: http://json-schema.org/draft-04/schema# +type: object +properties: + capabilities: + type: array + items: + type: string + interfaces: + type: array + items: + type: object + required: + - name + allOf: + - $ref: "#/definitions/interface-base/rw" + - $ref: "#/definitions/interface-base/ro" + - $ref: "#/definitions/interface-ip/all" + - $ref: "#/definitions/lldp/rw" + - $ref: "#/definitions/lldp/ro" + - oneOf: + - "$ref": "#/definitions/interface-unknown/rw" + - "$ref": "#/definitions/interface-ethernet/rw" + - "$ref": "#/definitions/interface-bond/rw" + - "$ref": "#/definitions/interface-linux-bridge/all" + - "$ref": "#/definitions/interface-ovs-bridge/all" + - "$ref": "#/definitions/interface-ovs-interface/rw" + - "$ref": "#/definitions/interface-dummy/rw" + - "$ref": "#/definitions/interface-vlan/rw" + - "$ref": "#/definitions/interface-vxlan/rw" + - "$ref": "#/definitions/interface-team/rw" + - "$ref": "#/definitions/interface-other/rw" + routes: + type: object + properties: + config: + type: array + items: + $ref: "#/definitions/route" + running: + type: array + items: + $ref: "#/definitions/route" + route-rules: + type: object + properties: + config: + type: array + items: + $ref: "#/definitions/route-rule" + dns-resolver: + type: object + properties: + config: + items: + $ref: "#/definitions/dns" + running: + items: + $ref: "#/definitions/dns" + +definitions: + types: + status: + type: string + enum: + - up + - down + mac-address: + type: string + pattern: "^([a-fA-F0-9]{2}:){3,31}[a-fA-F0-9]{2}$" + bridge-vlan-tag: + type: integer + minimum: 0 + maximum: 4095 + + # Interface types + interface-base: + all: + allOf: + - $ref: "#/definitions/interface-base/rw" + - $ref: "#/definitions/interface-base/ro" + rw: + properties: + description: + type: string + name: + type: string + state: + type: string + enum: + - absent + - up + - down + mac-address: + $ref: "#/definitions/types/mac-address" + mtu: + type: integer + minimum: 0 + ro: + properties: + if-index: + type: integer + minimum: 0 + admin-status: + $ref: "#/definitions/types/status" + link-status: + $ref: "#/definitions/types/status" + phys-address: + $ref: "#/definitions/types/mac-address" + higher-layer-if: + type: string + lower-layer-if: + type: string + statistics: + properties: + in-broadcast-pkts: + type: integer + minimum: 0 + in-discards: + type: integer + minimum: 0 + in-errors: + type: integer + minimum: 0 + in-multicast-pkts: + type: integer + minimum: 0 + in-octets: + type: integer + minimum: 0 + in-unicast-pkts: + type: integer + minimum: 0 + out-broadcast-pkts: + type: integer + minimum: 0 + out-discards: + type: integer + minimum: 0 + out-errors: + type: integer + minimum: 0 + out-multicast-pkts: + type: integer + minimum: 0 + out-octets: + type: integer + minimum: 0 + out-unicast-pkts: + type: integer + minimum: 0 + interface-unknown: + rw: + properties: + type: + type: string + enum: + - unknown + interface-ethernet: + rw: + properties: + type: + type: string + enum: + - ethernet + auto-negotiation: + type: boolean + duplex: + type: string + enum: + - full + - half + speed: + type: integer + minimum: 0 + flow-control: + type: boolean + sr-iov: + type: object + properties: + total-vfs: + type: integer + minimum: 0 + vfs: + type: array + items: + type: object + properties: + id: + type: integer + minimum: 0 + mac-address: + $ref: "#/definitions/types/mac-address" + spoof-check: + type: boolean + trust: + type: boolean + min-tx-rate: + type: integer + minimum: 0 + max-tx-rate: + type: integer + minimum: 0 + required: + - id + interface-vlan: + rw: + properties: + type: + type: string + enum: + - vlan + vlan: + type: object + properties: + id: + type: integer + minimum: 0 + maximum: 4095 + base-iface: + type: string + required: + - id + - base-iface + interface-vxlan: + rw: + properties: + type: + type: string + enum: + - vxlan + vxlan: + type: object + properties: + id: + type: integer + minimum: 0 + maximum: 16777215 + remote: + type: string + destination-port: + type: integer + base-iface: + type: string + + interface-bond: + rw: + properties: + type: + type: string + enum: + - bond + link-aggregation: + type: object + properties: + mode: + type: string + slaves: + type: array + items: + type: string + options: + type: object + interface-linux-bridge: + all: + allOf: + - $ref: "#/definitions/interface-linux-bridge/rw" + - $ref: "#/definitions/interface-linux-bridge/ro" + ro: + properties: + bridge: + type: object + properties: + options: + type: object + properties: + gc-timer: + type: integer + hello-timer: + type: integer + rw: + properties: + type: + type: string + enum: + - linux-bridge + bridge: + type: object + properties: + port: + type: array + items: + type: object + properties: + name: + type: string + stp-priority: + type: integer + stp-path-cost: + type: integer + stp-hairpin-mode: + type: boolean + vlan: + type: object + properties: + mode: + type: string + enum: + - trunk + - access + trunk-tags: + type: array + items: + $ref: "#/definitions/bridge-port-vlan" + tag: + $ref: "#/definitions/types/bridge-vlan-tag" + enable-native: + type: boolean + options: + type: object + properties: + mac-ageing-time: + type: integer + group-forward-mask: + type: integer + group-addr: + $ref: "#/definitions/types/mac-address" + hash-max: + type: integer + multicast-snooping: + type: boolean + multicast-router: + type: integer + multicast-last-member-count: + type: integer + multicast-last-member-interval: + type: integer + multicast-membership-interval: + type: integer + multicast-querier: + type: boolean + multicast-querier-interval: + type: integer + multicast-query-use-ifaddr: + type: boolean + multicast-query-interval: + type: integer + multicast-query-response-interval: + type: integer + multicast-router: + type: integer + multicast-startup-query-count: + type: integer + multicast-startup-query-interval: + type: integer + stp: + type: object + properties: + enabled: + type: boolean + priority: + type: integer + forward-delay: + type: integer + hello-time: + type: integer + max-age: + type: integer + interface-ovs-bridge: + all: + allOf: + - $ref: "#/definitions/interface-ovs-bridge/rw" + - $ref: "#/definitions/interface-ovs-bridge/ro" + rw: + properties: + type: + type: string + enum: + - ovs-bridge + ovs-db: + type: object + bridge: + type: object + properties: + port: + type: array + items: + type: object + properties: + name: + type: string + vlan: + type: object + properties: + mode: + type: string + enum: + - trunk + - access + trunk-tags: + type: array + items: + $ref: "#/definitions/bridge-port-vlan" + tag: + $ref: "#/definitions/types/bridge-vlan-tag" + enable-native: + type: boolean + link-aggregation: + type: object + properties: + mode: + type: string + slaves: + type: array + items: + type: object + properties: + name: + type: string + options: + type: object + properties: + stp: + type: boolean + rstp: + type: boolean + fail-mode: + type: string + mcast-snooping-enable: + type: boolean + ro: + properties: + bridge: + type: object + properties: + port: + type: array + items: + type: object + properties: + learned-mac-address: + type: array + items: + $ref: "#/definitions/types/mac-address" + interface-ovs-interface: + rw: + properties: + type: + type: string + enum: + - ovs-interface + ovs-db: + type: object + patch: + type: object + properties: + peer: + type: string + interface-dummy: + rw: + properties: + type: + type: string + enum: + - dummy + interface-ip: + all: + allOf: + - $ref: "#/definitions/interface-ip/rw" + - $ref: "#/definitions/interface-ip/ro" + rw: + properties: + ipv4: + type: object + properties: + enabled: + type: boolean + dhcp: + type: boolean + auto-routes: + type: boolean + auto-gateway: + type: boolean + auto-dns: + type: boolean + address: + type: array + items: + type: object + properties: + ip: + type: string + prefix-length: + type: integer + netmask: + type: string + neighbor: + type: array + items: + type: object + properties: + ip: + type: string + link-layer-address: + type: string + forwarding: + type: boolean + ipv6: + type: object + properties: + enabled: + type: boolean + autoconf: + type: boolean + dhcp: + type: boolean + auto-routes: + type: boolean + auto-gateway: + type: boolean + auto-dns: + type: boolean + address: + type: array + items: + type: object + properties: + ip: + type: string + prefix-length: + type: integer + neighbor: + type: array + items: + type: object + properties: + ip: + type: string + link-layer-address: + type: string + forwarding: + type: boolean + dup-addr-detect-transmits: + type: integer + ro: + properties: + ipv4: + type: object + properties: + address: + type: array + items: + type: object + properties: + origin: + type: string + neighbor: + type: array + items: + type: object + properties: + origin: + type: string + ipv6: + type: object + properties: + address: + type: array + items: + type: object + properties: + origin: + type: string + status: + type: string + neighbor: + type: array + items: + type: object + properties: + origin: + type: string + is-router: + type: boolean + state: + type: string + interface-team: + rw: + properties: + type: + type: string + enum: + - team + team: + type: object + properties: + ports: + type: array + items: + type: object + properties: + name: + type: string + runner: + type: object + properties: + name: + type: string + + interface-other: + rw: + properties: + type: + type: string + enum: + - other + route: + type: object + properties: + state: + type: string + enum: + - absent + table-id: + type: integer + metric: + type: integer + destination: + type: string + next-hop-interface: + type: string + next-hop-address: + type: string + dns: + type: object + properties: + server: + type: array + items: + type: string + search: + type: array + items: + type: string + bridge-port-vlan: + type: object + properties: + id: + $ref: "#/definitions/types/bridge-vlan-tag" + id-range: + type: object + properties: + min: + $ref: "#/definitions/types/bridge-vlan-tag" + max: + $ref: "#/definitions/types/bridge-vlan-tag" + route-rule: + type: object + properties: + from: + type: string + to: + type: string + priority: + type: integer + route-table: + type: integer + lldp: + ro: + properties: + neighbors: + type: array + items: + type: object + rw: + properties: + enabled: + type: boolean diff --git a/libnmstate/state.py b/libnmstate/state.py new file mode 100644 index 0000000..48c808f --- /dev/null +++ b/libnmstate/state.py @@ -0,0 +1,104 @@ +# +# Copyright (c) 2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +from abc import ABCMeta +from abc import abstractmethod +from collections.abc import Sequence +from collections.abc import Mapping +from functools import total_ordering + + +@total_ordering +class StateEntry(metaclass=ABCMeta): + @abstractmethod + def _keys(self): + """ + Return the tuple representing this entry, will be used for hashing or + comparing. + """ + pass + + def __hash__(self): + return hash(self._keys()) + + def __eq__(self, other): + return self is other or self._keys() == other._keys() + + def __lt__(self, other): + return self._keys() < other._keys() + + def __repr__(self): + return str(self.to_dict()) + + @property + @abstractmethod + def absent(self): + pass + + def to_dict(self): + return { + key.replace("_", "-"): value + for key, value in vars(self).items() + if (not key.startswith("_")) and (value is not None) + } + + def match(self, other): + """ + Match self against other. Treat self None attributes as wildcards, + matching against any value in others. + Return True for a match, False otherwise. + """ + for self_value, other_value in zip(self._keys(), other._keys()): + if self_value is not None and self_value != other_value: + return False + return True + + +def state_match(desire, current): + """ + Return True when all values defined in desire equal to value in current, + else False: + * For mapping(e.g. dict), desire could have less value than current. + * For sequnce(e.g. list), desire should equal to current. + """ + if isinstance(desire, Mapping): + return isinstance(current, Mapping) and all( + state_match(val, current.get(key)) for key, val in desire.items() + ) + elif isinstance(desire, Sequence) and not isinstance(desire, str): + return ( + isinstance(current, Sequence) + and not isinstance(current, str) + and len(current) == len(desire) + and all(state_match(d, c) for d, c in zip(desire, current)) + ) + else: + return desire == current + + +def merge_dict(dict_to, dict_from): + """ + Data will copy from `dict_from` if undefined in `dict_to`. + For list, the whole list is copied instead of merging. + """ + for key, from_value in dict_from.items(): + if key not in dict_to: + dict_to[key] = from_value + elif isinstance(dict_to[key], Mapping): + merge_dict(dict_to[key], from_value) diff --git a/libnmstate/validator.py b/libnmstate/validator.py new file mode 100644 index 0000000..02890b4 --- /dev/null +++ b/libnmstate/validator.py @@ -0,0 +1,88 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# + +import copy +import logging + +import jsonschema as js + +from libnmstate.ifaces.ovs import is_ovs_running +from libnmstate.schema import Interface +from libnmstate.schema import InterfaceType +from libnmstate.error import NmstateDependencyError + +from . import schema +from .plugin import NmstatePlugin + +MAX_SUPPORTED_INTERFACES = 1000 + + +def schema_validate(data, validation_schema=schema.ifaces_schema): + data = copy.deepcopy(data) + _validate_max_supported_intface_count(data) + for ifstate in data.get(schema.Interface.KEY, ()): + if not ifstate.get(schema.Interface.TYPE): + ifstate[schema.Interface.TYPE] = schema.InterfaceType.UNKNOWN + js.validate(data, validation_schema) + + +def validate_capabilities(state, capabilities): + validate_interface_capabilities(state.get(Interface.KEY, []), capabilities) + + +def validate_interface_capabilities(ifaces_state, capabilities): + ifaces_types = {iface_state.get("type") for iface_state in ifaces_state} + has_ovs_capability = NmstatePlugin.OVS_CAPABILITY in capabilities + has_team_capability = NmstatePlugin.TEAM_CAPABILITY in capabilities + ovs_is_running = is_ovs_running() + for iface_type in ifaces_types: + is_ovs_type = iface_type in ( + InterfaceType.OVS_BRIDGE, + InterfaceType.OVS_INTERFACE, + InterfaceType.OVS_PORT, + ) + if is_ovs_type and not has_ovs_capability: + if not ovs_is_running: + raise NmstateDependencyError( + "openvswitch service is not started." + ) + else: + raise NmstateDependencyError( + "Open vSwitch NetworkManager support not installed " + "and started" + ) + elif iface_type == InterfaceType.TEAM and not has_team_capability: + raise NmstateDependencyError( + "NetworkManager-team plugin not installed and started" + ) + + +def _validate_max_supported_intface_count(data): + """ + Raises warning if the interfaces count in the single desired state + exceeds the limit specified in MAX_SUPPORTED_INTERFACES + """ + num_of_interfaces = len( + [intface for intface in data.get(schema.Interface.KEY, ())] + ) + if num_of_interfaces > MAX_SUPPORTED_INTERFACES: + logging.warning( + "Interfaces count exceeds the limit %s in desired state", + MAX_SUPPORTED_INTERFACES, + ) diff --git a/nmstate.egg-info/PKG-INFO b/nmstate.egg-info/PKG-INFO new file mode 100644 index 0000000..6bea899 --- /dev/null +++ b/nmstate.egg-info/PKG-INFO @@ -0,0 +1,175 @@ +Metadata-Version: 2.1 +Name: nmstate +Version: 0.3.4 +Summary: Declarative network manager API +Home-page: https://nmstate.github.io/ +Author: Edward Haas +Author-email: ehaas@redhat.com +License: LGPL2.1+ +Description: # We are Nmstate! + A declarative network manager API for hosts. + + [![Test Status](https://travis-ci.com/nmstate/nmstate.png?branch=master)](https://travis-ci.com/nmstate/nmstate) + [![Coverage Status](https://coveralls.io/repos/github/nmstate/nmstate/badge.svg?branch=master)](https://coveralls.io/github/nmstate/nmstate?branch=master) + [![PyPI version](https://badge.fury.io/py/nmstate.svg)](https://badge.fury.io/py/nmstate) + [![Fedora Rawhide version](https://img.shields.io/badge/dynamic/json.svg?label=Fedora%20Rawhide&url=https%3A%2F%2Fapps.fedoraproject.org%2Fmdapi%2Frawhide%2Fpkg%2Fnmstate&query=%24.version&colorB=blue)](https://apps.fedoraproject.org/packages/nmstate) + [![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) + [![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/nmstate/nmstate.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/nmstate/nmstate/context:python) + + Copr build status, all repos are built for Fedora 31+ and RHEL/CentOS/EPEL 8: + + * Latest release: [![Latest release Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate/package/nmstate/) + * Git master: [![Git master Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-git/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-git/package/nmstate/) + * Latest 0.2 release: [![Latest 0.2 release Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2/package/nmstate/) + * Git nmstate-0.2: [![Git nmstate-0.2 Copr build status](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2-git/package/nmstate/status_image/last_build.png)](https://copr.fedorainfracloud.org/coprs/nmstate/nmstate-0.2-git/package/nmstate/) + + ## What is it? + Nmstate is a library with an accompanying command line tool that manages + host networking settings in a declarative manner. + The networking state is described by a pre-defined schema. + Reporting of current state and changes to it (desired state) both conform to + the schema. + + Nmstate is aimed to satisfy enterprise needs to manage host networking through + a northbound declarative API and multi provider support on the southbound. + NetworkManager acts as the main (and currently the only) provider supported. + + ## State example: + + Desired/Current state example (YAML): + ```yaml + interfaces: + - name: eth1 + type: ethernet + state: up + ipv4: + enabled: true + address: + - ip: 192.0.2.10 + prefix-length: 24 + dhcp: false + ipv6: + enabled: true + address: + - ip: 2001:db8:1::a + prefix-length: 64 + autoconf: false + dhcp: false + dns-resolver: + config: + search: + - example.com + - example.org + server: + - 2001:4860:4860::8888 + - 8.8.8.8 + routes: + config: + - destination: 0.0.0.0/0 + next-hop-address: 192.0.2.1 + next-hop-interface: eth1 + - destination: ::/0 + next-hop-address: 2001:db8:1::1 + next-hop-interface: eth1 + ``` + + ## Basic Operations + + Show eth0 current state (python/shell): + + ```python + import libnmstate + + state = libnmstate.show() + eth0_state = next(ifstate for ifstate in state['interfaces'] if ifstate['name'] == 'eth0') + + # Here is the MAC address + eth0_mac = eth0_state['mac-address'] + ``` + + ```shell + nmstatectl show eth0 + ``` + + Change to desired state (python/shell): + + ```python + import libnmstate + + # Specify a Linux bridge (created if it does not exist). + state = {'interfaces': [{'name': 'br0', 'type': 'linux-bridge', 'state': 'up'}]} + libnmstate.apply(state) + ``` + + ```shell + # use yaml or json formats + nmstatectl set desired-state.yml + nmstatectl set desired-state.json + ``` + + Edit the current state(python/shell): + ```python + import libnmstate + + state = libnmstate.show() + eth0_state = next(ifstate for ifstate in state['interfaces'] if ifstate['name'] == 'eth0') + + # take eth0 down + eth0_state['state'] = 'down' + libnmstate.apply(state) + ``` + + ```shell + # open current state in a text editor, change and save to apply + nmstatectl edit eth3 + ``` + + ## Contact + + *Nmstate* uses the [nmstate-devel@lists.fedorahosted.org][mailing_list] for + discussions. To subscribe you can send an email with 'subscribe' in the subject + to or visit the + [mailing list page][mailing_list]. + + Development planning (sprints and progress reporting) happens in + ([Jira](https://nmstate.atlassian.net)). Access requires login. + + There is also `#nmstate` on + [Freenode IRC](https://freenode.net/kb/answer/chat). + + ## Contributing + + Yay! We are happy to accept new contributors to the Nmstate project. Please follow + these [instructions](CONTRIBUTING.md) to contribute. + + ## Installation + + For Fedora 29+, `sudo dnf install nmstate`. + + For others distribution, please see the [install](README.install.md) + instructions. + + ## Documentation + + * [libnmstate API](https://nmstate.github.io/devel/api.html) + * [Code examples](https://nmstate.github.io/devel/py_example.html) + * [State examples](https://nmstate.github.io/examples.html) + * [nmstatectl user guide](https://nmstate.github.io/cli_guide.html) + * nmstatectl man page: `man nmstatectl` + + ## Limitations + + Please refer to [jira page][jira_limitation] for details. + + * Maximum supported number of interfaces in a single desire state is 1000. + + ## Changelog + + Please refer to [CHANGELOG](CHANGELOG) + + + [jira_limitation]: https://nmstate.atlassian.net/issues/?filter=10003 + [mailing_list]: https://lists.fedorahosted.org/admin/lists/nmstate-devel.lists.fedorahosted.org + +Platform: UNKNOWN +Description-Content-Type: text/markdown diff --git a/nmstate.egg-info/SOURCES.txt b/nmstate.egg-info/SOURCES.txt new file mode 100644 index 0000000..fee9e7a --- /dev/null +++ b/nmstate.egg-info/SOURCES.txt @@ -0,0 +1,94 @@ +LICENSE +MANIFEST.in +README.md +pyproject.toml +requirements.txt +setup.py +doc/nmstatectl.8 +doc/nmstatectl.8.in +examples/bond_linuxbridge_vlan_absent.yml +examples/bond_linuxbridge_vlan_up.yml +examples/dns_edit_eth1.yml +examples/dns_remove.yml +examples/eth1_add_route.yml +examples/eth1_del_all_routes.yml +examples/eth1_with_sriov.yml +examples/linuxbrige_eth1_absent.yml +examples/linuxbrige_eth1_up.yml +examples/linuxbrige_eth1_up_port_vlan.yml +examples/ovsbridge_bond_create.yml +examples/ovsbridge_create.yml +examples/ovsbridge_delete.yml +examples/ovsbridge_patch_create.yml +examples/ovsbridge_patch_delete.yml +examples/ovsbridge_vlan_port.yml +examples/team0_absent.yml +examples/team0_with_slaves.yml +examples/vlan101_eth1_absent.yml +examples/vlan101_eth1_down.yml +examples/vlan101_eth1_up.yml +libnmstate/VERSION +libnmstate/__init__.py +libnmstate/dns.py +libnmstate/error.py +libnmstate/ethtool.py +libnmstate/iplib.py +libnmstate/net_state.py +libnmstate/netapplier.py +libnmstate/netinfo.py +libnmstate/nmstate.py +libnmstate/plugin.py +libnmstate/prettystate.py +libnmstate/route.py +libnmstate/route_rule.py +libnmstate/schema.py +libnmstate/state.py +libnmstate/validator.py +libnmstate/ifaces/__init__.py +libnmstate/ifaces/base_iface.py +libnmstate/ifaces/bond.py +libnmstate/ifaces/bridge.py +libnmstate/ifaces/dummy.py +libnmstate/ifaces/ethernet.py +libnmstate/ifaces/ifaces.py +libnmstate/ifaces/linux_bridge.py +libnmstate/ifaces/ovs.py +libnmstate/ifaces/team.py +libnmstate/ifaces/vlan.py +libnmstate/ifaces/vxlan.py +libnmstate/nm/__init__.py +libnmstate/nm/active_connection.py +libnmstate/nm/applier.py +libnmstate/nm/bond.py +libnmstate/nm/bridge.py +libnmstate/nm/bridge_port_vlan.py +libnmstate/nm/checkpoint.py +libnmstate/nm/common.py +libnmstate/nm/connection.py +libnmstate/nm/context.py +libnmstate/nm/device.py +libnmstate/nm/dns.py +libnmstate/nm/ipv4.py +libnmstate/nm/ipv6.py +libnmstate/nm/lldp.py +libnmstate/nm/ovs.py +libnmstate/nm/plugin.py +libnmstate/nm/route.py +libnmstate/nm/sriov.py +libnmstate/nm/team.py +libnmstate/nm/translator.py +libnmstate/nm/user.py +libnmstate/nm/vlan.py +libnmstate/nm/vxlan.py +libnmstate/nm/wired.py +libnmstate/plugins/__init__.py +libnmstate/plugins/nmstate_plugin_ovsdb.py +libnmstate/schemas/operational-state.yaml +nmstate.egg-info/PKG-INFO +nmstate.egg-info/SOURCES.txt +nmstate.egg-info/dependency_links.txt +nmstate.egg-info/entry_points.txt +nmstate.egg-info/requires.txt +nmstate.egg-info/top_level.txt +nmstatectl/__init__.py +nmstatectl/nmstatectl.py \ No newline at end of file diff --git a/nmstate.egg-info/dependency_links.txt b/nmstate.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/nmstate.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/nmstate.egg-info/entry_points.txt b/nmstate.egg-info/entry_points.txt new file mode 100644 index 0000000..d790057 --- /dev/null +++ b/nmstate.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +nmstatectl = nmstatectl.nmstatectl:main + diff --git a/nmstate.egg-info/requires.txt b/nmstate.egg-info/requires.txt new file mode 100644 index 0000000..4d57c20 --- /dev/null +++ b/nmstate.egg-info/requires.txt @@ -0,0 +1,4 @@ +jsonschema +PyGObject +PyYAML +setuptools diff --git a/nmstate.egg-info/top_level.txt b/nmstate.egg-info/top_level.txt new file mode 100644 index 0000000..62722c6 --- /dev/null +++ b/nmstate.egg-info/top_level.txt @@ -0,0 +1,2 @@ +libnmstate +nmstatectl diff --git a/nmstatectl/__init__.py b/nmstatectl/__init__.py new file mode 100644 index 0000000..380f7d8 --- /dev/null +++ b/nmstatectl/__init__.py @@ -0,0 +1,18 @@ +# +# Copyright (c) 2018-2019 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# diff --git a/nmstatectl/nmstatectl.py b/nmstatectl/nmstatectl.py new file mode 100644 index 0000000..103b6a1 --- /dev/null +++ b/nmstatectl/nmstatectl.py @@ -0,0 +1,444 @@ +# +# Copyright (c) 2018-2020 Red Hat, Inc. +# +# This file is part of nmstate +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 2.1 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# +import argparse +import errno +import fnmatch +import json +import logging +import os +import subprocess +import sys +import tempfile + +import yaml + +import libnmstate +from libnmstate import PrettyState +from libnmstate.error import NmstateConflictError +from libnmstate.error import NmstatePermissionError +from libnmstate.error import NmstateValueError +from libnmstate.schema import Interface +from libnmstate.schema import Route +from libnmstate.schema import RouteRule + + +def main(): + logging.basicConfig( + format="%(asctime)s %(name)-12s %(levelname)-8s %(message)s", + level=logging.DEBUG, + ) + + parser = argparse.ArgumentParser() + + subparsers = parser.add_subparsers() + setup_subcommand_commit(subparsers) + setup_subcommand_edit(subparsers) + setup_subcommand_rollback(subparsers) + setup_subcommand_set(subparsers) + setup_subcommand_show(subparsers) + setup_subcommand_version(subparsers) + parser.add_argument( + "--version", action="store_true", help="Display nmstate version" + ) + + if len(sys.argv) == 1: + parser.print_usage() + return errno.EINVAL + args = parser.parse_args() + if args.version: + print(libnmstate.__version__) + else: + return args.func(args) + + +def setup_subcommand_commit(subparsers): + parser_commit = subparsers.add_parser("commit", help="Commit a change") + parser_commit.add_argument( + "checkpoint", nargs="?", default=None, help="checkpoint to commit" + ) + parser_commit.set_defaults(func=commit) + + +def setup_subcommand_edit(subparsers): + parser_edit = subparsers.add_parser( + "edit", help="Edit network state in EDITOR" + ) + parser_edit.set_defaults(func=edit) + parser_edit.add_argument( + "--json", + help="Edit as JSON", + default=True, + action="store_false", + dest="yaml", + ) + parser_edit.add_argument( + "only", + default="*", + nargs="?", + metavar=Interface.KEY, + help="Edit only specified interfaces (comma-separated)", + ) + parser_edit.add_argument( + "--no-verify", + action="store_false", + dest="verify", + default=True, + help="Do not verify that the state was completely set and disable " + "rollback to previous state.", + ) + parser_edit.add_argument( + "--memory-only", + action="store_false", + dest="save_to_disk", + default=True, + help="Do not make the state persistent.", + ) + + +def setup_subcommand_rollback(subparsers): + parser_rollback = subparsers.add_parser( + "rollback", help="Rollback a change" + ) + parser_rollback.add_argument( + "checkpoint", nargs="?", default=None, help="checkpoint to roll back" + ) + parser_rollback.set_defaults(func=rollback) + + +def setup_subcommand_set(subparsers): + parser_set = subparsers.add_parser("set", help="Set network state") + parser_set.add_argument( + "file", + help="File containing desired state. " + "stdin is used when no file is specified.", + nargs="*", + ) + parser_set.add_argument( + "--no-verify", + action="store_false", + dest="verify", + default=True, + help="Do not verify that the state was completely set and disable " + "rollback to previous state", + ) + parser_set.add_argument( + "--no-commit", + action="store_false", + dest="commit", + default=True, + help="Do not commit new state after verification", + ) + parser_set.add_argument( + "--timeout", + type=int, + default=60, + help="Timeout in seconds before reverting uncommited changes.", + ) + parser_set.add_argument( + "--memory-only", + action="store_false", + dest="save_to_disk", + default=True, + help="Do not make the state persistent.", + ) + parser_set.set_defaults(func=apply) + + +def setup_subcommand_show(subparsers): + parser_show = subparsers.add_parser("show", help="Show network state") + parser_show.set_defaults(func=show) + parser_show.add_argument( + "--json", + help="Edit as JSON", + default=True, + action="store_false", + dest="yaml", + ) + parser_show.add_argument( + "only", + default="*", + nargs="?", + metavar=Interface.KEY, + help="Show only specified interfaces (comma-separated)", + ) + + +def setup_subcommand_version(subparsers): + parser_version = subparsers.add_parser( + "version", help="Display nmstate version" + ) + parser_version.set_defaults(func=version) + + +def version(args): + print(libnmstate.__version__) + + +def commit(args): + try: + libnmstate.commit(checkpoint=args.checkpoint) + except NmstateValueError as e: + print("ERROR committing change: {}\n".format(str(e))) + return os.EX_DATAERR + + +def edit(args): + state = _filter_state(libnmstate.show(), args.only) + + if not state[Interface.KEY]: + sys.stderr.write("ERROR: No such interface\n") + return os.EX_USAGE + + pretty_state = PrettyState(state) + + if args.yaml: + suffix = ".yaml" + txtstate = pretty_state.yaml + else: + suffix = ".json" + txtstate = pretty_state.json + + new_state = _get_edited_state(txtstate, suffix, args.yaml) + if not new_state: + return os.EX_DATAERR + + print("Applying the following state: ") + print_state(new_state, use_yaml=args.yaml) + + libnmstate.apply( + new_state, verify_change=args.verify, save_to_disk=args.save_to_disk + ) + + +def rollback(args): + try: + libnmstate.rollback(checkpoint=args.checkpoint) + except NmstateValueError as e: + print("ERROR rolling back change: {}\n".format(str(e))) + return os.EX_DATAERR + + +def show(args): + state = _filter_state(libnmstate.show(), args.only) + print_state(state, use_yaml=args.yaml) + + +def apply(args): + if args.file: + for statefile in args.file: + if statefile == "-" and not os.path.isfile(statefile): + statedata = sys.stdin.read() + else: + with open(statefile) as statefile: + statedata = statefile.read() + + ret = apply_state( + statedata, + args.verify, + args.commit, + args.timeout, + args.save_to_disk, + ) + if ret: + return ret + elif not sys.stdin.isatty(): + statedata = sys.stdin.read() + return apply_state( + statedata, + args.verify, + args.commit, + args.timeout, + args.save_to_disk, + ) + else: + sys.stderr.write("ERROR: No state specified\n") + return 1 + + +def apply_state(statedata, verify_change, commit, timeout, save_to_disk): + use_yaml = False + # JSON dictionaries start with a curly brace + if statedata[0] == "{": + state = json.loads(statedata) + else: + state = yaml.load(statedata, Loader=yaml.SafeLoader) + use_yaml = True + + try: + checkpoint = libnmstate.apply( + state, + verify_change=verify_change, + commit=commit, + rollback_timeout=timeout, + save_to_disk=save_to_disk, + ) + except NmstatePermissionError as e: + sys.stderr.write("ERROR: Missing permissions:{}\n".format(str(e))) + return os.EX_NOPERM + except NmstateConflictError: + sys.stderr.write( + "ERROR: State editing already in progress.\n" + "Commit, roll back or wait before retrying.\n" + ) + return os.EX_UNAVAILABLE + + print("Desired state applied: ") + print_state(state, use_yaml=use_yaml) + if checkpoint: + print("Checkpoint: {}".format(checkpoint)) + + +def _filter_state(state, whitelist): + if whitelist != "*": + patterns = [p for p in whitelist.split(",")] + state[Interface.KEY] = _filter_interfaces(state, patterns) + state[Route.KEY] = _filter_routes(state, patterns) + state[RouteRule.KEY] = _filter_route_rule(state) + return state + + +def _filter_interfaces(state, patterns): + """ + return the states for all interfaces from `state` that match at least one + of the provided patterns. + """ + showinterfaces = [] + + for interface in state[Interface.KEY]: + for pattern in patterns: + if fnmatch.fnmatch(interface["name"], pattern): + showinterfaces.append(interface) + break + return showinterfaces + + +def _get_edited_state(txtstate, suffix, use_yaml): + while True: + txtstate = _run_editor(txtstate, suffix) + + if txtstate is None: + return None + + new_state, error = _parse_state(txtstate, use_yaml) + + if error: + if not _try_edit_again(error): + return None + else: + return new_state + + +def _run_editor(txtstate, suffix): + editor = os.environ.get("EDITOR", "vi") + with tempfile.NamedTemporaryFile( + suffix=suffix, prefix="nmstate-" + ) as statefile: + statefile.write(txtstate.encode("utf-8")) + statefile.flush() + + try: + subprocess.check_call([editor, statefile.name]) + statefile.seek(0) + return statefile.read() + + except subprocess.CalledProcessError: + sys.stderr.write("Error running editor, aborting...\n") + return None + + +def _parse_state(txtstate, parse_yaml): + error = "" + state = {} + if parse_yaml: + try: + state = yaml.load(txtstate, Loader=yaml.SafeLoader) + except yaml.parser.ParserError as e: + error = "Invalid YAML syntax: %s\n" % e + except yaml.parser.ScannerError as e: + error = "Invalid YAML syntax: %s\n" % e + else: + try: + state = json.loads(txtstate) + except ValueError as e: + error = "Invalid JSON syntax: %s\n" % e + + if not error and Interface.KEY not in state: + # Allow editing routes only. + state[Interface.KEY] = [] + + return state, error + + +def _try_edit_again(error): + """ + Print error and ask for user feedback. Return True, if the state should be + edited again and False otherwise. + """ + + sys.stderr.write("ERROR: " + error) + response = "" + while response not in ("y", "n"): + response = input( + "Try again? [y,n]:\n" + "y - yes, start editor again\n" + "n - no, throw away my changes\n" + "> " + ).lower() + if response == "n": + return False + return True + + +def print_state(state, use_yaml=False): + state = PrettyState(state) + if use_yaml: + sys.stdout.write(state.yaml) + else: + print(state.json) + + +def _filter_routes(state, patterns): + """ + return the states for all routes from `state` that match at least one + of the provided patterns. + """ + routes = {Route.CONFIG: [], Route.RUNNING: []} + for route_type in (Route.RUNNING, Route.CONFIG): + for route in state.get(Route.KEY, {}).get(route_type, []): + for pattern in patterns: + if fnmatch.fnmatch(route[Route.NEXT_HOP_INTERFACE], pattern): + routes[route_type].append(route) + return routes + + +def _filter_route_rule(state): + """ + return the rules for state's route rule that match the table_id of the + filtered route of state by interface + """ + route_rules = {RouteRule.CONFIG: []} + table_ids = [] + for routes in {Route.CONFIG: [], Route.RUNNING: []}: + for route in state.get(Route.KEY, {}).get(routes, []): + if route.get(Route.TABLE_ID) not in table_ids: + table_ids.append(route.get(Route.TABLE_ID)) + for rule in state.get(RouteRule.KEY, {}).get(RouteRule.CONFIG, []): + if rule.get(RouteRule.ROUTE_TABLE) in table_ids: + route_rules[RouteRule.CONFIG].append(rule) + return route_rules diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7a3d70c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[build-system] +requires = ["setuptools", "wheel"] + +[tool.black] +line-length = 79 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..435f5eb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# Requirements for Python package distribution (pip) +# Use like this: +#packageA +#packageB!=4.4,>=4.0 +#packageC>=3.0.0 +#packageD!=0.13.0,<0.14,>=0.12.0 + +jsonschema +PyGObject +PyYAML +setuptools diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8bfd5a1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[egg_info] +tag_build = +tag_date = 0 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..17fddd4 --- /dev/null +++ b/setup.py @@ -0,0 +1,54 @@ +from setuptools import setup, find_packages +from datetime import date + + +def readme(): + with open("README.md") as f: + return f.read() + + +def requirements(): + req = [] + with open("requirements.txt") as fd: + for line in fd: + line.strip() + if not line.startswith("#"): + req.append(line) + return req + + +def get_version(): + with open("libnmstate/VERSION") as f: + version = f.read().strip() + return version + + +def gen_manpage(): + manpage = "" + with open("doc/nmstatectl.8.in") as f: + manpage = f.read() + manpage = manpage.replace("@DATE@", date.today().strftime("%B %d, %Y")) + manpage = manpage.replace("@VERSION@", get_version()) + with open("doc/nmstatectl.8", "w") as f: + f.write(manpage) + return [("share/man/man8", ["doc/nmstatectl.8"])] + + +setup( + name="nmstate", + version=get_version(), + description="Declarative network manager API", + author="Edward Haas", + author_email="ehaas@redhat.com", + long_description=readme(), + long_description_content_type="text/markdown", + url="https://nmstate.github.io/", + license="LGPL2.1+", + packages=find_packages(), + install_requires=requirements(), + entry_points={ + "console_scripts": ["nmstatectl = nmstatectl.nmstatectl:main"] + }, + package_data={"libnmstate": ["schemas/operational-state.yaml", "VERSION"]}, + data_files=gen_manpage(), +)