diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..700707c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..8d4b079 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,75 @@ +name: CI +on: + push: + branches: + - main + tags: ['*'] + pull_request: + workflow_dispatch: +concurrency: + # Skip intermediate builds: always. + # Cancel intermediate builds: only if it is a pull request build. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} +jobs: + test: + name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: # needed to allow julia-actions/cache to proactively delete old caches that it has created + actions: write + contents: read + strategy: + fail-fast: false + matrix: + version: + - '1.10' + - 'nightly' + os: + - ubuntu-latest + arch: + - x64 + - x86 + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v1 + with: + version: ${{ matrix.version }} + arch: ${{ matrix.arch }} + - uses: julia-actions/cache@v1 + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-runtest@v1 + - uses: julia-actions/julia-processcoverage@v1 + - uses: codecov/codecov-action@v3 + with: + files: lcov.info + docs: + name: Documentation + runs-on: ubuntu-latest + permissions: + actions: write # needed to allow julia-actions/cache to proactively delete old caches that it has created + contents: write + statuses: write + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v1 + with: + version: '1' + - uses: julia-actions/cache@v1 + - name: Configure doc environment + shell: julia --project=docs --color=yes {0} + run: | + using Pkg + Pkg.develop(PackageSpec(path=pwd())) + Pkg.instantiate() + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-docdeploy@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run doctests + shell: julia --project=docs --color=yes {0} + run: | + using Documenter: DocMeta, doctest + using Rembus + DocMeta.setdocmeta!(Rembus, :DocTestSetup, :(using Rembus); recursive=true) + doctest(Rembus) diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml new file mode 100644 index 0000000..cba9134 --- /dev/null +++ b/.github/workflows/CompatHelper.yml @@ -0,0 +1,16 @@ +name: CompatHelper +on: + schedule: + - cron: 0 0 * * * + workflow_dispatch: +jobs: + CompatHelper: + runs-on: ubuntu-latest + steps: + - name: Pkg.add("CompatHelper") + run: julia -e 'using Pkg; Pkg.add("CompatHelper")' + - name: CompatHelper.main() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMPATHELPER_PRIV: ${{ secrets.DOCUMENTER_KEY }} + run: julia -e 'using CompatHelper; CompatHelper.main()' diff --git a/.github/workflows/TagBot.yml b/.github/workflows/TagBot.yml new file mode 100644 index 0000000..2bacdb8 --- /dev/null +++ b/.github/workflows/TagBot.yml @@ -0,0 +1,31 @@ +name: TagBot +on: + issue_comment: + types: + - created + workflow_dispatch: + inputs: + lookback: + default: 3 +permissions: + actions: read + checks: read + contents: write + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + repository-projects: read + security-events: read + statuses: read +jobs: + TagBot: + if: github.event_name == 'workflow_dispatch' || github.actor == 'JuliaTagBot' + runs-on: ubuntu-latest + steps: + - uses: JuliaRegistries/TagBot@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ssh: ${{ secrets.DOCUMENTER_KEY }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb67b66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.jl.*.cov +*.jl.cov +*.jl.mem +*.sav +/docs/Manifest.toml +/docs/build/ +/build +/keystore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d20770 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM julia:1.10.2 + +WORKDIR /caronte + +COPY build . + +EXPOSE 8000 +EXPOSE 8001 +EXPOSE 8002 + +ENV REMBUS_DB="/db" + +ENTRYPOINT ["bin/caronte"] + + diff --git a/KEYSTORE.md b/KEYSTORE.md new file mode 100644 index 0000000..6806be0 --- /dev/null +++ b/KEYSTORE.md @@ -0,0 +1,24 @@ +# Secure connections setup + +Encrypted WebSocket and TLS connections need keys and certificates adhering to the following rules: + +- The rembus broker private key is named `caronte.key`. +- The rembus signed certificate is named `caronte.crt`. +- The environment variable `REMBUS_KEYSTORE` define the directory where `caronte.key` and `caronte.crt` must be located. +- The full path of the certificate or the bundle containing the CA that signed `caronte.crt` must be specified with the standard env variable `HTTP_CA_BUNDLE`. + +`$REMBUS_KEYSTORE/rembus-ca.crt` is the default value for `HTTP_CA_BUNDLE` if it is unset. + +## Secrets materials: quick bootstrapping + +The utility script `init keystore` may be tweaked or used as is to generate the secrets and a CA: + +```shell +myhost:Rembus.jl> bin/init_keystore [-n server_dns] [-i ip_address] [-k keystore_dir] +``` + +### options + +- `-i` register an ip address that may be used to connect securely. +- `-n` DNS server name (default to `caronte`). +- `-k` generated directory name containing the secret materials (default to `$HOME/keystore`). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7974ebf --- /dev/null +++ b/LICENSE @@ -0,0 +1,677 @@ +Copyright (C) 2024 Attilio Donà, Claudio Carraro + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, 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 +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If 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 convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "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 PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM 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 PROGRAM (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 PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state 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 program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Manifest.toml b/Manifest.toml new file mode 100644 index 0000000..0c47ba2 --- /dev/null +++ b/Manifest.toml @@ -0,0 +1,575 @@ +# This file is machine-generated - editing it directly is not advised + +julia_version = "1.10.2" +manifest_format = "2.0" +project_hash = "3d32120aa01f2d49d2640e9b4452922369f876c4" + +[[deps.ArgParse]] +deps = ["Logging", "TextWrap"] +git-tree-sha1 = "d4eccacaa3a632e8717556479d45502af44b4c17" +uuid = "c7e460c6-2fb9-53a9-8c5b-16f535851c63" +version = "1.1.5" + +[[deps.ArgTools]] +uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" +version = "1.1.1" + +[[deps.Arrow]] +deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "LoggingExtras", "Mmap", "PooledArrays", "SentinelArrays", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"] +git-tree-sha1 = "29faa9835f77dee04b2833b2c9ee415223b3ebbd" +uuid = "69666777-d1a9-59fb-9406-91d4454c9d45" +version = "2.7.1" + +[[deps.ArrowTypes]] +deps = ["Sockets", "UUIDs"] +git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101" +uuid = "31f734f8-188a-4ce0-8406-c8a06bd891cd" +version = "2.3.0" + +[[deps.Artifacts]] +uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" + +[[deps.Base64]] +uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" + +[[deps.BitFlags]] +git-tree-sha1 = "2dc09997850d68179b69dafb58ae806167a32b1b" +uuid = "d1d4a3ce-64b1-5f1a-9ba4-7e7e69966f35" +version = "0.1.8" + +[[deps.BitIntegers]] +deps = ["Random"] +git-tree-sha1 = "a55462dfddabc34bc97d3a7403a2ca2802179ae6" +uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1" +version = "0.3.1" + +[[deps.CSV]] +deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "PrecompileTools", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings", "WorkerUtilities"] +git-tree-sha1 = "a44910ceb69b0d44fe262dd451ab11ead3ed0be8" +uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +version = "0.10.13" + +[[deps.CodecLz4]] +deps = ["Lz4_jll", "TranscodingStreams"] +git-tree-sha1 = "b8aecef9f90530cf322a8386630ec18485c17991" +uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561" +version = "0.4.3" + +[[deps.CodecZlib]] +deps = ["TranscodingStreams", "Zlib_jll"] +git-tree-sha1 = "59939d8a997469ee05c4b4944560a820f9ba0d73" +uuid = "944b1d66-785c-5afd-91f1-9de20f533193" +version = "0.7.4" + +[[deps.CodecZstd]] +deps = ["TranscodingStreams", "Zstd_jll"] +git-tree-sha1 = "23373fecba848397b1705f6183188a0c0bc86917" +uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2" +version = "0.8.2" + +[[deps.Compat]] +deps = ["TOML", "UUIDs"] +git-tree-sha1 = "c955881e3c981181362ae4088b35995446298b80" +uuid = "34da2185-b29b-5c13-b0c7-acf172513d20" +version = "4.14.0" +weakdeps = ["Dates", "LinearAlgebra"] + + [deps.Compat.extensions] + CompatLinearAlgebraExt = "LinearAlgebra" + +[[deps.CompilerSupportLibraries_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" +version = "1.1.0+0" + +[[deps.ConcurrentUtilities]] +deps = ["Serialization", "Sockets"] +git-tree-sha1 = "6cbbd4d241d7e6579ab354737f4dd95ca43946e1" +uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb" +version = "2.4.1" + +[[deps.Crayons]] +git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15" +uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" +version = "4.1.1" + +[[deps.DataAPI]] +git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe" +uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" +version = "1.16.0" + +[[deps.DataFrames]] +deps = ["Compat", "DataAPI", "DataStructures", "Future", "InlineStrings", "InvertedIndices", "IteratorInterfaceExtensions", "LinearAlgebra", "Markdown", "Missings", "PooledArrays", "PrecompileTools", "PrettyTables", "Printf", "REPL", "Random", "Reexport", "SentinelArrays", "SortingAlgorithms", "Statistics", "TableTraits", "Tables", "Unicode"] +git-tree-sha1 = "04c738083f29f86e62c8afc341f0967d8717bdb8" +uuid = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +version = "1.6.1" + +[[deps.DataStructures]] +deps = ["Compat", "InteractiveUtils", "OrderedCollections"] +git-tree-sha1 = "0f4b5d62a88d8f59003e43c25a8a90de9eb76317" +uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" +version = "0.18.18" + +[[deps.DataValueInterfaces]] +git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" +uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464" +version = "1.0.0" + +[[deps.Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" + +[[deps.Distributed]] +deps = ["Random", "Serialization", "Sockets"] +uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b" + +[[deps.DocStringExtensions]] +deps = ["LibGit2"] +git-tree-sha1 = "2fb1e02f2b635d0845df5d7c167fec4dd739b00d" +uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" +version = "0.9.3" + +[[deps.Downloads]] +deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] +uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +version = "1.6.0" + +[[deps.EnumX]] +git-tree-sha1 = "bdb1942cd4c45e3c678fd11569d5cccd80976237" +uuid = "4e289a0a-7415-4d19-859d-a7e5c4648b56" +version = "1.0.4" + +[[deps.ExceptionUnwrapping]] +deps = ["Test"] +git-tree-sha1 = "dcb08a0d93ec0b1cdc4af184b26b591e9695423a" +uuid = "460bff9d-24e4-43bc-9d9f-a8973cb893f4" +version = "0.1.10" + +[[deps.ExprTools]] +git-tree-sha1 = "27415f162e6028e81c72b82ef756bf321213b6ec" +uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" +version = "0.1.10" + +[[deps.FilePathsBase]] +deps = ["Compat", "Dates", "Mmap", "Printf", "Test", "UUIDs"] +git-tree-sha1 = "9f00e42f8d99fdde64d40c8ea5d14269a2e2c1aa" +uuid = "48062228-2e41-5def-b9a4-89aafe57970f" +version = "0.9.21" + +[[deps.FileWatching]] +uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" + +[[deps.Future]] +deps = ["Random"] +uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" + +[[deps.HTTP]] +deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"] +git-tree-sha1 = "995f762e0182ebc50548c434c171a5bb6635f8e4" +uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" +version = "1.10.4" + +[[deps.InlineStrings]] +deps = ["Parsers"] +git-tree-sha1 = "9cc2baf75c6d09f9da536ddf58eb2f29dedaf461" +uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48" +version = "1.4.0" + +[[deps.InteractiveUtils]] +deps = ["Markdown"] +uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" + +[[deps.InvertedIndices]] +git-tree-sha1 = "0dc7b50b8d436461be01300fd8cd45aa0274b038" +uuid = "41ab1584-1d38-5bbf-9106-f11c6c58b48f" +version = "1.3.0" + +[[deps.IteratorInterfaceExtensions]] +git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856" +uuid = "82899510-4779-5014-852e-03e436cf321d" +version = "1.0.0" + +[[deps.JLLWrappers]] +deps = ["Artifacts", "Preferences"] +git-tree-sha1 = "7e5d6779a1e09a36db2a7b6cff50942a0a7d0fca" +uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" +version = "1.5.0" + +[[deps.JSON3]] +deps = ["Dates", "Mmap", "Parsers", "PrecompileTools", "StructTypes", "UUIDs"] +git-tree-sha1 = "eb3edce0ed4fa32f75a0a11217433c31d56bd48b" +uuid = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +version = "1.14.0" +weakdeps = ["ArrowTypes"] + + [deps.JSON3.extensions] + JSON3ArrowExt = ["ArrowTypes"] + +[[deps.LaTeXStrings]] +git-tree-sha1 = "50901ebc375ed41dbf8058da26f9de442febbbec" +uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f" +version = "1.3.1" + +[[deps.LazyArtifacts]] +deps = ["Artifacts", "Pkg"] +uuid = "4af54fe1-eca0-43a8-85a7-787d91b784e3" + +[[deps.LibCURL]] +deps = ["LibCURL_jll", "MozillaCACerts_jll"] +uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" +version = "0.6.4" + +[[deps.LibCURL_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"] +uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" +version = "8.4.0+0" + +[[deps.LibGit2]] +deps = ["Base64", "LibGit2_jll", "NetworkOptions", "Printf", "SHA"] +uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" + +[[deps.LibGit2_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll"] +uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5" +version = "1.6.4+0" + +[[deps.LibSSH2_jll]] +deps = ["Artifacts", "Libdl", "MbedTLS_jll"] +uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" +version = "1.11.0+1" + +[[deps.Libdl]] +uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" + +[[deps.LinearAlgebra]] +deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] +uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" + +[[deps.Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" + +[[deps.LoggingExtras]] +deps = ["Dates", "Logging"] +git-tree-sha1 = "c1dd6d7978c12545b4179fb6153b9250c96b0075" +uuid = "e6f89c97-d47a-5376-807f-9c37f3926c36" +version = "1.0.3" + +[[deps.Lz4_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "6c26c5e8a4203d43b5497be3ec5d4e0c3cde240a" +uuid = "5ced341a-0733-55b8-9ab6-a4889d929147" +version = "1.9.4+0" + +[[deps.Markdown]] +deps = ["Base64"] +uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" + +[[deps.MbedTLS]] +deps = ["Dates", "MbedTLS_jll", "MozillaCACerts_jll", "NetworkOptions", "Random", "Sockets"] +git-tree-sha1 = "c067a280ddc25f196b5e7df3877c6b226d390aaf" +uuid = "739be429-bea8-5141-9913-cc70e7f3736d" +version = "1.1.9" + +[[deps.MbedTLS_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" +version = "2.28.2+1" + +[[deps.Missings]] +deps = ["DataAPI"] +git-tree-sha1 = "f66bdc5de519e8f8ae43bdc598782d35a25b1272" +uuid = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28" +version = "1.1.0" + +[[deps.Mmap]] +uuid = "a63ad114-7e13-5084-954f-fe012c677804" + +[[deps.Mocking]] +deps = ["Compat", "ExprTools"] +git-tree-sha1 = "4cc0c5a83933648b615c36c2b956d94fda70641e" +uuid = "78c3b35d-d492-501b-9361-3d52fe80e533" +version = "0.7.7" + +[[deps.MozillaCACerts_jll]] +uuid = "14a3606d-f60d-562e-9121-12d972cd8159" +version = "2023.1.10" + +[[deps.NetworkOptions]] +uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" +version = "1.2.0" + +[[deps.OpenBLAS_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] +uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" +version = "0.3.23+4" + +[[deps.OpenSSL]] +deps = ["BitFlags", "Dates", "MozillaCACerts_jll", "OpenSSL_jll", "Sockets"] +git-tree-sha1 = "af81a32750ebc831ee28bdaaba6e1067decef51e" +uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c" +version = "1.4.2" + +[[deps.OpenSSL_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "60e3045590bd104a16fefb12836c00c0ef8c7f8c" +uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" +version = "3.0.13+0" + +[[deps.OrderedCollections]] +git-tree-sha1 = "dfdf5519f235516220579f949664f1bf44e741c5" +uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +version = "1.6.3" + +[[deps.Parameters]] +deps = ["OrderedCollections", "UnPack"] +git-tree-sha1 = "34c0e9ad262e5f7fc75b10a9952ca7692cfc5fbe" +uuid = "d96e819e-fc66-5662-9728-84c9c7592b0a" +version = "0.12.3" + +[[deps.Parsers]] +deps = ["Dates", "PrecompileTools", "UUIDs"] +git-tree-sha1 = "8489905bcdbcfac64d1daa51ca07c0d8f0283821" +uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" +version = "2.8.1" + +[[deps.Pkg]] +deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"] +uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +version = "1.10.0" + +[[deps.PooledArrays]] +deps = ["DataAPI", "Future"] +git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3" +uuid = "2dfb63ee-cc39-5dd5-95bd-886bf059d720" +version = "1.4.3" + +[[deps.PrecompileTools]] +deps = ["Preferences"] +git-tree-sha1 = "5aa36f7049a63a1528fe8f7c3f2113413ffd4e1f" +uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +version = "1.2.1" + +[[deps.Preferences]] +deps = ["TOML"] +git-tree-sha1 = "9306f6085165d270f7e3db02af26a400d580f5c6" +uuid = "21216c6a-2e73-6563-6e65-726566657250" +version = "1.4.3" + +[[deps.PrettyTables]] +deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "Reexport", "StringManipulation", "Tables"] +git-tree-sha1 = "88b895d13d53b5577fd53379d913b9ab9ac82660" +uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" +version = "2.3.1" + +[[deps.Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" + +[[deps.REPL]] +deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] +uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" + +[[deps.Random]] +deps = ["SHA"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" + +[[deps.Reexport]] +git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" +uuid = "189a3867-3050-52da-a836-e630ba90ab69" +version = "1.2.2" + +[[deps.SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[deps.Scratch]] +deps = ["Dates"] +git-tree-sha1 = "3bac05bc7e74a75fd9cba4295cde4045d9fe2386" +uuid = "6c6a2e73-6563-6170-7368-637461726353" +version = "1.2.1" + +[[deps.SentinelArrays]] +deps = ["Dates", "Random"] +git-tree-sha1 = "0e7508ff27ba32f26cd459474ca2ede1bc10991f" +uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c" +version = "1.4.1" + +[[deps.Serialization]] +uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" + +[[deps.SimpleBufferStream]] +git-tree-sha1 = "874e8867b33a00e784c8a7e4b60afe9e037b74e1" +uuid = "777ac1f9-54b0-4bf8-805c-2214025038e7" +version = "1.1.0" + +[[deps.Sockets]] +uuid = "6462fe0b-24de-5631-8697-dd941f90decc" + +[[deps.SortingAlgorithms]] +deps = ["DataStructures"] +git-tree-sha1 = "66e0a8e672a0bdfca2c3f5937efb8538b9ddc085" +uuid = "a2af1166-a08f-5f64-846c-94a0d3cef48c" +version = "1.2.1" + +[[deps.SparseArrays]] +deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"] +uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" +version = "1.10.0" + +[[deps.Statistics]] +deps = ["LinearAlgebra", "SparseArrays"] +uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +version = "1.10.0" + +[[deps.StringManipulation]] +deps = ["PrecompileTools"] +git-tree-sha1 = "a04cabe79c5f01f4d723cc6704070ada0b9d46d5" +uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" +version = "0.3.4" + +[[deps.StructTypes]] +deps = ["Dates", "UUIDs"] +git-tree-sha1 = "ca4bccb03acf9faaf4137a9abc1881ed1841aa70" +uuid = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" +version = "1.10.0" + +[[deps.SuiteSparse_jll]] +deps = ["Artifacts", "Libdl", "libblastrampoline_jll"] +uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" +version = "7.2.1+1" + +[[deps.TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" + +[[deps.TZJData]] +deps = ["Artifacts"] +git-tree-sha1 = "b69f8338df046774bd975b13be9d297eca5efacb" +uuid = "dc5dba14-91b3-4cab-a142-028a31da12f7" +version = "1.1.0+2023d" + +[[deps.TableTraits]] +deps = ["IteratorInterfaceExtensions"] +git-tree-sha1 = "c06b2f539df1c6efa794486abfb6ed2022561a39" +uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c" +version = "1.0.1" + +[[deps.Tables]] +deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "OrderedCollections", "TableTraits"] +git-tree-sha1 = "cb76cf677714c095e535e3501ac7954732aeea2d" +uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" +version = "1.11.1" + +[[deps.Tar]] +deps = ["ArgTools", "SHA"] +uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" +version = "1.10.0" + +[[deps.Test]] +deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] +uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[[deps.TextWrap]] +git-tree-sha1 = "9250ef9b01b66667380cf3275b3f7488d0e25faf" +uuid = "b718987f-49a8-5099-9789-dcd902bef87d" +version = "1.0.1" + +[[deps.TimeZones]] +deps = ["Artifacts", "Dates", "Downloads", "InlineStrings", "LazyArtifacts", "Mocking", "Printf", "Scratch", "TZJData", "Unicode", "p7zip_jll"] +git-tree-sha1 = "89e64d61ef3cd9e80f7fc12b7d13db2d75a23c03" +uuid = "f269a46b-ccf7-5d73-abea-4c690281aa53" +version = "1.13.0" + + [deps.TimeZones.extensions] + TimeZonesRecipesBaseExt = "RecipesBase" + + [deps.TimeZones.weakdeps] + RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" + +[[deps.TranscodingStreams]] +git-tree-sha1 = "a09c933bebed12501890d8e92946bbab6a1690f1" +uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" +version = "0.10.5" +weakdeps = ["Random", "Test"] + + [deps.TranscodingStreams.extensions] + TestExt = ["Test", "Random"] + +[[deps.URIs]] +git-tree-sha1 = "67db6cc7b3821e19ebe75791a9dd19c9b1188f2b" +uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" +version = "1.5.1" + +[[deps.UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" + +[[deps.UnPack]] +git-tree-sha1 = "387c1f73762231e86e0c9c5443ce3b4a0a9a0c2b" +uuid = "3a884ed6-31ef-47d7-9d2a-63182c4928ed" +version = "1.0.2" + +[[deps.Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" + +[[deps.Visor]] +deps = ["DataStructures", "Dates", "Logging", "UUIDs"] +git-tree-sha1 = "232783e703febab4ccb5e651fd562fbf673df8ff" +uuid = "cf786855-3531-4b86-ba6e-3e33dce7dcdb" +version = "0.6.0" + +[[deps.WeakRefStrings]] +deps = ["DataAPI", "InlineStrings", "Parsers"] +git-tree-sha1 = "b1be2855ed9ed8eac54e5caff2afcdb442d52c23" +uuid = "ea10d353-3f73-51f8-a26c-33c1cb351aa5" +version = "1.4.2" + +[[deps.WorkerUtilities]] +git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7" +uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60" +version = "1.6.1" + +[[deps.ZMQ]] +deps = ["FileWatching", "Sockets", "ZeroMQ_jll"] +git-tree-sha1 = "356d2bdcc0bce90aabee1d1c0f6d6f301eda8f77" +uuid = "c2297ded-f4af-51ae-bb23-16f91089e4e1" +version = "1.2.2" + +[[deps.ZeroMQ_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "libsodium_jll"] +git-tree-sha1 = "42f97fb27394378591666ab0e9cee369e6d0e1f9" +uuid = "8f1865be-045e-5c20-9c9f-bfbfb0764568" +version = "4.3.5+0" + +[[deps.Zlib_jll]] +deps = ["Libdl"] +uuid = "83775a58-1f1d-513f-b197-d71354ab007a" +version = "1.2.13+1" + +[[deps.Zstd_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "49ce682769cd5de6c72dcf1b94ed7790cd08974c" +uuid = "3161d3a3-bdf6-5164-811a-617609db77b4" +version = "1.5.5+0" + +[[deps.libblastrampoline_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" +version = "5.8.0+1" + +[[deps.libsodium_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] +git-tree-sha1 = "848ab3d00fe39d6fbc2a8641048f8f272af1c51e" +uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8" +version = "1.0.20+0" + +[[deps.nghttp2_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" +version = "1.52.0+1" + +[[deps.p7zip_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" +version = "17.4.0+2" diff --git a/Project.toml b/Project.toml new file mode 100644 index 0000000..a7dad77 --- /dev/null +++ b/Project.toml @@ -0,0 +1,65 @@ +name = "Rembus" +uuid = "aa126574-2ec3-42c9-8bf5-7f62001d6bce" +authors = ["Attilio "] +version = "0.1.0" + +[deps] +ArgParse = "c7e460c6-2fb9-53a9-8c5b-16f535851c63" +Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45" +Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" +DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" +FileWatching = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" +MbedTLS = "739be429-bea8-5141-9913-cc70e7f3736d" +Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a" +PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +Preferences = "21216c6a-2e73-6563-6e65-726566657250" +Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +Reexport = "189a3867-3050-52da-a836-e630ba90ab69" +Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" +Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" +URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" +UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +Visor = "cf786855-3531-4b86-ba6e-3e33dce7dcdb" +ZMQ = "c2297ded-f4af-51ae-bb23-16f91089e4e1" + +[compat] +ArgParse = "1" +Arrow = "2" +CSV = "0.10" +DataFrames = "1" +DataStructures = "0.18" +DocStringExtensions = "0.9" +HTTP = "1" +JSON3 = "1" +MbedTLS = "1" +Parameters = "0.12" +PrecompileTools = "1" +Preferences = "1" +Reexport = "1" +URIs = "1" +Visor = "0" +ZMQ = "1" + +[extras] +SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[preferences.Rembus] +metering = false +offline_store = "disk" +overwrite_connection = true +pathlib = "lib" +rembus_cfg = "CaronteExt" +stacktrace = false + +[targets] +test = ["Test", "SafeTestsets"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b77ce3a --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Rembus + +[![Stable](https://img.shields.io/badge/docs-stable-blue.svg)](https://attdona.github.io/Rembus.jl/stable/) +[![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://attdona.github.io/Rembus.jl/dev/) +[![Build Status](https://github.com/attdona/Rembus.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/attdona/Rembus.jl/actions/workflows/CI.yml?query=branch%3Amain) +[![Coverage](https://codecov.io/gh/attdona/Rembus.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/attdona/Rembus.jl) +[![Code Style: Blue](https://img.shields.io/badge/code%20style-blue-4495d1.svg)](https://github.com/invenia/BlueStyle) + +Rembus is a middleware to implement high performance and fault-tolerant distributed applications. + +## Key Features + +* Built-in support for exchanging DataFrames. + +* Macro-based API that make writing RPC and Pub/Sub applications simple and fast. + +* Multiple transport protocols: Tcp, Web Socket, ZeroMQ. + +* Binary message encoding using [CBOR](https://cbor.io/). + +## The broker + +Start the broker: + +```sh +julia -e "using Rembus; caronte()" +``` + +## RPC server + +```julia +@component "myserver" + +function myservice(arg1) + return "hello $arg1 💗" +end + +@expose myservice + +# Serve forever until Ctrl-C +# in REPL forever() is not needed +forever() +``` + +> The `@component` macro declares a unique name for the component that get known to the broker. +> On the broker side such identity permits to bind a twin operating on the behalf of the component either when it is offline. + +## RPC client + +```julia +response = @rpc myservice("rembus") +``` + +> When a name is not declared with `@component` then a random uuid identifier is associated with the component each time the application starts. + +## Pub/Sub subscriber + +```julia +@component "myconsumer" + +function mytopic(df::DataFrame) + println("mean_a=$(mean(df.a)), mean_b=$(mean(df.b))") +end + +@subscribe mytopic + +# forever() serves forever until Ctrl-C +# in REPL forever() is not needed +forever() +``` + +## Pub/Sub publisher + +```julia +df = DataFrame(a=1:1_000_000, b=rand(1_000_000)) + +@publish mytopic(df) +``` diff --git a/bin/caronte b/bin/caronte new file mode 100755 index 0000000..0122f1d --- /dev/null +++ b/bin/caronte @@ -0,0 +1,9 @@ +#!/bin/bash +#= +BINDIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +exec julia --threads auto --color=no -e "include(popfirst!(ARGS))" \ + --project=$BINDIR/.. --startup-file=no "${BASH_SOURCE[0]}" "$@" +=# +using Rembus + +Rembus.caronte() \ No newline at end of file diff --git a/bin/create_app b/bin/create_app new file mode 100755 index 0000000..bf0fe9b --- /dev/null +++ b/bin/create_app @@ -0,0 +1,16 @@ +#!/bin/bash +BIN_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +PKG_DIR=$(dirname $BIN_DIR) + +cat << EOF | julia --startup-file=no --threads auto --project=${PKG_DIR} + +using PackageCompiler + +try + create_app(".", "build"; executables = ["caronte"=>"caronted"], force=true) +catch e + println(e) + exit(1) +end +EOF + diff --git a/bin/init_keystore b/bin/init_keystore new file mode 100755 index 0000000..99cde2f --- /dev/null +++ b/bin/init_keystore @@ -0,0 +1,78 @@ +#! /bin/bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +NAME=caronte +DNS_NAME=$NAME +KEYSTORE=${HOME}/keystore +TRUSTED_IP_ADDRESS="" + +echo $KEYSTORE + +while getopts 'n:k:i:h' opt; do + case "$opt" in + k) + KEYSTORE=${OPTARG} + ;; + n) + DNS_NAME=${OPTARG} + ;; + i) + TRUSTED_IP_ADDRESS=${OPTARG} + ;; + ?|h) + echo "Usage: $(basename $0) [-n server_dns] [-i ip_address] [-k keystore_dir]" + exit 1 + ;; + esac +done +shift "$(($OPTIND -1))" + +if [ -d $KEYSTORE ] +then + echo "keystore dir exists, init aborted" + exit 1 +else + mkdir $KEYSTORE + cd $KEYSTORE +fi + +# Create root CA +CA_CRT=rembus-ca.crt +CA_KEY=rembus-ca.key + +openssl req -x509 \ + -sha256 -days 356 \ + -nodes \ + -newkey rsa:2048 \ + -subj "/CN=Rembus/C=IT/L=Trento" \ + -keyout ${CA_KEY} -out ${CA_CRT} + +# create ext file +cat > ${NAME}.ext <> ${NAME}.ext < "index.md", + "Configuration" => "configuration.md", + "Plain API" => "api.md", + "Supervised API" => "supervised_api.md", + ], +) + +deploydocs(; repo="github.com/cardo-org/Rembus.jl", devbranch="main") diff --git a/docs/src/api.md b/docs/src/api.md new file mode 100644 index 0000000..ae918d5 --- /dev/null +++ b/docs/src/api.md @@ -0,0 +1,27 @@ +# Plain API + +There is a set of functions that provide a plain API: + +- connect +- publish +- rpc +- expose +- subscribe +- unexpose +- unsubscribe +- close + +The `connect` function returns a connection handle used by the other APIs for exchanging data and commands. + +> This API does not provide automatic reconnection in case of network +failures, if this happen the exception must be handled explicitly by the application. + +```julia +using Rembus + +rb = connect("mycomponent") + +publish(rb, "metric", Dict("name"=>"trento/castello", "var"=>"T", "value"=>21.0)) + +close(rb) +``` diff --git a/docs/src/cheatsheet.md b/docs/src/cheatsheet.md new file mode 100644 index 0000000..3711c46 --- /dev/null +++ b/docs/src/cheatsheet.md @@ -0,0 +1,97 @@ +# Rembus Cheat Sheet + +## Startup and teardown + +Connect to the broker with identity `myname`: + +```julia +@component "myname" +``` + +Close the connection and terminate the component: + +```julia +@terminate +``` + +Loop unless `Ctrl-C` or `shutdown()`: + +```julia +forever() +``` + +> **NOTE:** `forever` is required by `@subscribe` and `@expose` unless you are in the REPL. + +Terminate background Rembus task and return from `forever()`: + +```julia +shutdown() +``` + +## Pub/Sub: 1 publisher and N subscribers + +Publish a message with topic `mytopic` and data payload that is the CBOR encoding +of `[arg1, arg2, arg3]`: + +```julia +@publish mytopic(arg1, arg2, arg3) +``` + +Subscribe to topic `mytopic`, the arguments `arg1, arg2, arg3` are the CBOR decoded +values of the data payload: + +```julia +# Method `mytopic` is called for each published message. +function mytopic(arg1, arg2, arg3) + # do something +end + +# Two different modes of subscription: +@subscribe mytopic from_now # declare interest to topic mytopic handling newer messages +@subscribe mytopic before_now # messages from the past and not received because offline +@subscribe mytopic # default to from_now +``` + +Start and stop to call subscribed methods when a published message is received: + +```reactive +@reactive +@reactive_off +``` + +Remove the topic subscription: + +```julia +@unsubscribe mytopic +``` + +By default reactive in enabled. + +## Remote Procedure Call + +Call the remote method `myrpc` exposed by a component: + +```julia +response = @rpc myrpc(arg1, arg2) +``` + +> **NOTE:** in case of successfull invocation the `response` value is the remote method return value, othervise an exception is thrown. + +Expose a method implementation: + +```julia +function myrpc(arg1, arg2) + # evaluate body and return response ... + return response +end + +@expose myrpc(arg1, arg2) +``` + +Stop to serve the RPC method: + +```julia +@unexpose myrpc +``` + + diff --git a/docs/src/configuration.md b/docs/src/configuration.md new file mode 100644 index 0000000..01a5948 --- /dev/null +++ b/docs/src/configuration.md @@ -0,0 +1,147 @@ +# Configuartion + +## Broker environment variables + +The broker setup is affected by the following environment variables. + +| Variable |Default| Descr | +|----------|-------|-------| +|`BROKER_DB`|\$HOME/caronte | Root dir for configuration files and cached messages to be delivered to offline components opting for retroactive mode| +|`BROKER_TCP_PORT`|8000|use `tls://:$BROKER_TCP_PORT` for serving TLS protocol| +|`BROKER_WS_PORT`|8001|use `wss://:$BROKER_WS_PORT` for serving WSS protocol| +|`BROKER_ZMQ_PORT`|8002|ZeroMQ port `zmq://:$BROKER_ZMQ_PORT`| +|`REMBUS_DEBUG`|0| "1": enable debug traces| +|`REMBUS_KEYSTORE`|\$HOME/keystore| Directory of broker certificate `caronte.crt` and broker secret key `caronte.key`| + +## Component environment variables + +A Rembus component is affected by the following environement variables. + +| Variable |Default| Descr | +|----------|-------|-------| +|`REMBUS_BASE_URL`|ws://localhost:8000|Default base url when defining component with a simple string instead of a complete url. `@component "myclient"` is equivalent to `@component` `"ws://localhost:8000/myclient"`| +|`REMBUS_CA`|rembus-ca.crt|CA certificate file name. This file has to be in `$REMBUS_KEYSTORE` directory| +|`REMBUS_DEBUG`|0| "1": enable debug traces| +|`REMBUS_KEYSTORE`|\$HOME/keystore| Directory of CA certificate| +|`REMBUS_TIMEOUT`|5| Maximum number of seconds waiting for rpc responses| +|`HTTP_CA_BUNDLE`|\$REMBUS\_KEYSTORE/\$REMBUS\_CA|CA certificate| + +## Database structure + +Rembus configuration data and secret materials is persisted to `BROKER_DB` directory. + +The database directory has the following layout: + +```sh +> cd $REMBUS_DB +> tree . + +. +├── admins.json +├── apps +│   ├── bar +│   └── foo +├── impls.json +├── interests.json +├── owners.csv +├── token_app.csv +├── topic_auth.json +├── twins +    ├── bar +    └── foo + + +``` + +where `foo` and `bar` are the component identifiers (`cid`) of the two registered components that have at least one retroactive topic. + +In case the component are offline the undelivered messages are temporarly persisted into `twins/bar` and `/twins/foo` files. + +### Admin privileges + +`admins.json` contains the array of `cid` entries that have admin permission. + +```text +> cat admins.json +["foo", "bar"] +``` + +### RPC implementors + +`impls.json` is a map with `topic` as keywords and an array of `cid` as values that define which component implements which rpc topic. + +For example: + +```text +> cat impls.json +{ + "topic_1":["foo"], + "topic_2":["foo", "bar"] +} +``` + +* `foo` component implements `topic_1` and `topic_2` rpc methods. +* `bar` component implements `topic_2` rpc method. + +### PubSub subscribers + +`twins.json` is a map with `cid` as keyword and a map as value. + +The keys of the map are the subscribed topics and the boolean value is true if the +subscription is retroactive: + +```text +> cat twins.json +{ + "mycomponent":{"mytopic1": true, "mytopic2": false} +} +``` + +`mycomponent` is interested to `mytopic1` and `mytopic2` messages, and `mytopic2` subscribed +with option `before_now`: + +```julia +@subscribe mytopic2 before_now +``` + +### Private topics + +`topic_auth.json` is a map with `topic` as keywords and an array of `cid` as values. + +For example if: + +```text +> cat topic_auth..json +{ + "foo":["myconsumer","myproducer"] +} +``` + +then only components `myconsumer` and `myproducer` are allowed to bind to the topic `foo`. + +### Users authorized to register components + +`owners.csv` is a csv file containing users allowed to register components. + +The `pin` column is the PIN token needed for registration. + +```text +> cat owners.csv +pin,uid,name,enabled +482dc7eb,paperoga@topolinia.com,Paperoga,false +58e26283,paperino@topolinia.com,Paperino,false +``` + +### Components ownership + +`token_app.csv` is a csv file containing the mapping between the registered components and the user that performed the registration. + +`uid` is the user identity and `app` is the component identifier. + +For example if the user Paperoga registered the component `foo` then: + +```text +> cat token_app.csv +uid,app +paperoga@topolinia.com,foo +``` diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 0000000..1a52b41 --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,96 @@ +# Rembus + +```@meta +CurrentModule = Rembus +``` + +## Broker + +Starting the broker is simple as: + +```sh +julia -e "using Rembus; caronte()" +``` + +Providing a startup script could be useful. The following `caronte` script suffice: + +```julia +##!/bin/bash +#= +BINDIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +exec julia --threads auto --color=no -e "include(popfirst!(ARGS))" \ + --project=$BINDIR/.. --startup-file=no "${BASH_SOURCE[0]}" "$@" +=# +using Rembus + +Rembus.caronte() +``` + +If `caronte` is in `PATH` then executing: + +```sh +shell> caronte +``` + +starts the broker with setup controlled by the [Broker environment variables](@ref). + +## Component + +A `Component` is a broker client who uses the Rembus protocol for RPC commands and +for streaming data in a Pub/Sub fashion. + +The macro `@component` declares a component whom identity and the connection parameters are defined with an URL: + +```julia +component_url = "[://][][:/]" + +@component component_url +``` + +`` is one of: + +- `ws` web socket +- `wss` secure web socket +- `tcp` tcp socket +- `tls` TLS over tcp socket +- `zmq` ZeroMQ socket + +`` and `` are the hostname/ip and the port of the broker listening endpoint. + +`` is the unique name of the component. + +For example: + +```julia +@component "ws://caronte.org:8000/myclient" +``` + +defines the component `myclient` that communicates with the broker hosted on `caronte.org`, listening on port `8000` and accepting web socket connections. + +### Default component URL parameters + +The string that define a component may be simplified by using the enviroment +variable `REMBUS_BASE_URL` that set the connection default parameters: + +For example: + +```sh +REMBUS_BASE_URL=ws://localhost:8000 +``` + +define the default protocol, host and port, so that the above `component_url` may be simplified as: + +```julia +@component "myclient" +``` + +Uses the web socket protocol to connect to `localhost` on port `8000`. + +## Index + +```@index +``` + +```@autodocs +Modules = [Rembus] +``` diff --git a/docs/src/supervised_api.md b/docs/src/supervised_api.md new file mode 100644 index 0000000..3f8e173 --- /dev/null +++ b/docs/src/supervised_api.md @@ -0,0 +1,25 @@ +# Supervised API + +Rembus aims to write distributed applications simple and fun. + +But beside struggling to provide a simple and lean API one of the main points of Rembus is its ability to be fault-tolerant respect to networks and application failures. + +For example the following RPC service will run forever and it will reconnect +automatically to the broker in case of network failures or to broker unavailabity due +to shutdown or failures, there aren't boilerplates for reliable connection management. + +```julia +@component "mycomponent" + +function myservice(input::DataFrame) + # run your super-cool logic and get back the result + result = my_logic(input) + return df +end + +@expose myservice + +forever() +``` + +Fault-tolerance holds equally for publish/subscribe setups: connection failures recovers automatically and published messages are cached and delivered as soon as possible. diff --git a/examples/Examples.md b/examples/Examples.md new file mode 100644 index 0000000..dc30b5e --- /dev/null +++ b/examples/Examples.md @@ -0,0 +1,110 @@ +# Examples + +To play with the examples gets the required dependencies: + +``` +> alias j='julia --project=. --startup-file=no' +> j -e 'using Pkg; Pkg.develop(url=".."); Pkg.instantiate()' +``` +and then starts a broker application: + +``` +> j 'using Rembus; caronte()' +``` + +## subscriber.jl + +In standard parlance `subscriber.jl` example implements a message subscriber/consumer: an application that listen to messages published to a set of logical channels, usually called topics. + +Indeed the example implements also a RPC service and show that a DataFrame-based message is a first-class citizen of the Rembus middleware. + +To see it in action: + +```shell +> j subscriber.jl +``` + +Open a REPL and send some messages: + +```julia +using Rembus + +@publish announcement("geppeto", "Version 2.0 of pinocchio is a beautiful boy") +@publish announcement("pinocchio", "Cat and Fox are my new friends") +``` + +To get back all published announcements invoke the RPC service `all_announcements`: + +```julia +df = @rpc all_announcements() +``` + +Oh yes! The returned value is a `DataFrame`. + + +Below there are some more details just for exposing the core concepts of Rembus. + +### The minimal subscriber + +In a distributed system governed by the Rembus middleware there are two types of applications, Brokers and Components: + +- a Broker routes messages between Components; +- a Component sends and receives messages and may be a publisher, a subscriber, a RPC client, a RPC Server + or all of these roles; + +The first step for a Component application is to bind to a Broker and declare its name: + +```julia +@component "ws://caronte.com:8000/organizer" +``` +The above declares a component `organizer` that will connect to the broker hosted at `caronte.com` with protocol `ws` served at port `8000`. + +> There are some sensible defaults that may help to keep the code clean: +> the url of the broker may be set with the environment variable REMBUS_BASE_URL: +> +> `export REMBUS_BASE_URL=://:` +> +> If `REMBUS_BASE_URL` is not defined the default url will be `ws://127.0.0.1:8000`. +> +> In this case the component may be declared as: +> +> `@component "organizer"` + + +Suppose the `organizer` wants to receive all messages published to the `announcement` topic. + +The messages are expected to have two fields: an username and a string containing a message announcement. + +> In general messages exchanged between distributed components may have any numbers of fields with primitive types mapped to [CBOR](https://www.rfc-editor.org/rfc/rfc8949.html#name-cbor-data-models) types. + +How do we consume such messages? + +With a method which name equals to the topic name and with a number of arguments equals to the message fields: + +```julia +function announcement(username, post) + # do something with the post of username +end +``` + +What remain to do is elevate such julia method as a consumer of the topic `announcement` +using the macro `@subscribe`: + +```julia +@subscribe announcement +``` + +The full code for this minimal `organizer` component that you can run in a REPL is then: + +```julia +using Rembus + +@component "organizer" + +function announcement(username, post) + println("[$username]: $post") +end + +@subscribe announcement + +``` diff --git a/examples/Manifest.toml b/examples/Manifest.toml new file mode 100644 index 0000000..02751a9 --- /dev/null +++ b/examples/Manifest.toml @@ -0,0 +1,581 @@ +# This file is machine-generated - editing it directly is not advised + +julia_version = "1.10.2" +manifest_format = "2.0" +project_hash = "075ed3b1e4800ec37f8a72c8501c7bc2ead0d0a6" + +[[deps.ArgParse]] +deps = ["Logging", "TextWrap"] +git-tree-sha1 = "d4eccacaa3a632e8717556479d45502af44b4c17" +uuid = "c7e460c6-2fb9-53a9-8c5b-16f535851c63" +version = "1.1.5" + +[[deps.ArgTools]] +uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" +version = "1.1.1" + +[[deps.Arrow]] +deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "LoggingExtras", "Mmap", "PooledArrays", "SentinelArrays", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"] +git-tree-sha1 = "29faa9835f77dee04b2833b2c9ee415223b3ebbd" +uuid = "69666777-d1a9-59fb-9406-91d4454c9d45" +version = "2.7.1" + +[[deps.ArrowTypes]] +deps = ["Sockets", "UUIDs"] +git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101" +uuid = "31f734f8-188a-4ce0-8406-c8a06bd891cd" +version = "2.3.0" + +[[deps.Artifacts]] +uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" + +[[deps.Base64]] +uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" + +[[deps.BitFlags]] +git-tree-sha1 = "2dc09997850d68179b69dafb58ae806167a32b1b" +uuid = "d1d4a3ce-64b1-5f1a-9ba4-7e7e69966f35" +version = "0.1.8" + +[[deps.BitIntegers]] +deps = ["Random"] +git-tree-sha1 = "a55462dfddabc34bc97d3a7403a2ca2802179ae6" +uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1" +version = "0.3.1" + +[[deps.CSV]] +deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "PrecompileTools", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings", "WorkerUtilities"] +git-tree-sha1 = "a44910ceb69b0d44fe262dd451ab11ead3ed0be8" +uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +version = "0.10.13" + +[[deps.CodecLz4]] +deps = ["Lz4_jll", "TranscodingStreams"] +git-tree-sha1 = "b8aecef9f90530cf322a8386630ec18485c17991" +uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561" +version = "0.4.3" + +[[deps.CodecZlib]] +deps = ["TranscodingStreams", "Zlib_jll"] +git-tree-sha1 = "59939d8a997469ee05c4b4944560a820f9ba0d73" +uuid = "944b1d66-785c-5afd-91f1-9de20f533193" +version = "0.7.4" + +[[deps.CodecZstd]] +deps = ["TranscodingStreams", "Zstd_jll"] +git-tree-sha1 = "23373fecba848397b1705f6183188a0c0bc86917" +uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2" +version = "0.8.2" + +[[deps.Compat]] +deps = ["TOML", "UUIDs"] +git-tree-sha1 = "c955881e3c981181362ae4088b35995446298b80" +uuid = "34da2185-b29b-5c13-b0c7-acf172513d20" +version = "4.14.0" +weakdeps = ["Dates", "LinearAlgebra"] + + [deps.Compat.extensions] + CompatLinearAlgebraExt = "LinearAlgebra" + +[[deps.CompilerSupportLibraries_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" +version = "1.1.0+0" + +[[deps.ConcurrentUtilities]] +deps = ["Serialization", "Sockets"] +git-tree-sha1 = "6cbbd4d241d7e6579ab354737f4dd95ca43946e1" +uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb" +version = "2.4.1" + +[[deps.Crayons]] +git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15" +uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" +version = "4.1.1" + +[[deps.DataAPI]] +git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe" +uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" +version = "1.16.0" + +[[deps.DataFrames]] +deps = ["Compat", "DataAPI", "DataStructures", "Future", "InlineStrings", "InvertedIndices", "IteratorInterfaceExtensions", "LinearAlgebra", "Markdown", "Missings", "PooledArrays", "PrecompileTools", "PrettyTables", "Printf", "REPL", "Random", "Reexport", "SentinelArrays", "SortingAlgorithms", "Statistics", "TableTraits", "Tables", "Unicode"] +git-tree-sha1 = "04c738083f29f86e62c8afc341f0967d8717bdb8" +uuid = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +version = "1.6.1" + +[[deps.DataStructures]] +deps = ["Compat", "InteractiveUtils", "OrderedCollections"] +git-tree-sha1 = "0f4b5d62a88d8f59003e43c25a8a90de9eb76317" +uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" +version = "0.18.18" + +[[deps.DataValueInterfaces]] +git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" +uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464" +version = "1.0.0" + +[[deps.Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" + +[[deps.Distributed]] +deps = ["Random", "Serialization", "Sockets"] +uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b" + +[[deps.DocStringExtensions]] +deps = ["LibGit2"] +git-tree-sha1 = "2fb1e02f2b635d0845df5d7c167fec4dd739b00d" +uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" +version = "0.9.3" + +[[deps.Downloads]] +deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] +uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +version = "1.6.0" + +[[deps.EnumX]] +git-tree-sha1 = "bdb1942cd4c45e3c678fd11569d5cccd80976237" +uuid = "4e289a0a-7415-4d19-859d-a7e5c4648b56" +version = "1.0.4" + +[[deps.ExceptionUnwrapping]] +deps = ["Test"] +git-tree-sha1 = "dcb08a0d93ec0b1cdc4af184b26b591e9695423a" +uuid = "460bff9d-24e4-43bc-9d9f-a8973cb893f4" +version = "0.1.10" + +[[deps.ExprTools]] +git-tree-sha1 = "27415f162e6028e81c72b82ef756bf321213b6ec" +uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" +version = "0.1.10" + +[[deps.FilePathsBase]] +deps = ["Compat", "Dates", "Mmap", "Printf", "Test", "UUIDs"] +git-tree-sha1 = "9f00e42f8d99fdde64d40c8ea5d14269a2e2c1aa" +uuid = "48062228-2e41-5def-b9a4-89aafe57970f" +version = "0.9.21" + +[[deps.FileWatching]] +uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" + +[[deps.Future]] +deps = ["Random"] +uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" + +[[deps.HTTP]] +deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"] +git-tree-sha1 = "995f762e0182ebc50548c434c171a5bb6635f8e4" +uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" +version = "1.10.4" + +[[deps.InlineStrings]] +deps = ["Parsers"] +git-tree-sha1 = "9cc2baf75c6d09f9da536ddf58eb2f29dedaf461" +uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48" +version = "1.4.0" + +[[deps.InteractiveUtils]] +deps = ["Markdown"] +uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" + +[[deps.InvertedIndices]] +git-tree-sha1 = "0dc7b50b8d436461be01300fd8cd45aa0274b038" +uuid = "41ab1584-1d38-5bbf-9106-f11c6c58b48f" +version = "1.3.0" + +[[deps.IteratorInterfaceExtensions]] +git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856" +uuid = "82899510-4779-5014-852e-03e436cf321d" +version = "1.0.0" + +[[deps.JLLWrappers]] +deps = ["Artifacts", "Preferences"] +git-tree-sha1 = "7e5d6779a1e09a36db2a7b6cff50942a0a7d0fca" +uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" +version = "1.5.0" + +[[deps.JSON3]] +deps = ["Dates", "Mmap", "Parsers", "PrecompileTools", "StructTypes", "UUIDs"] +git-tree-sha1 = "eb3edce0ed4fa32f75a0a11217433c31d56bd48b" +uuid = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +version = "1.14.0" +weakdeps = ["ArrowTypes"] + + [deps.JSON3.extensions] + JSON3ArrowExt = ["ArrowTypes"] + +[[deps.LaTeXStrings]] +git-tree-sha1 = "50901ebc375ed41dbf8058da26f9de442febbbec" +uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f" +version = "1.3.1" + +[[deps.LazyArtifacts]] +deps = ["Artifacts", "Pkg"] +uuid = "4af54fe1-eca0-43a8-85a7-787d91b784e3" + +[[deps.LibCURL]] +deps = ["LibCURL_jll", "MozillaCACerts_jll"] +uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" +version = "0.6.4" + +[[deps.LibCURL_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"] +uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" +version = "8.4.0+0" + +[[deps.LibGit2]] +deps = ["Base64", "LibGit2_jll", "NetworkOptions", "Printf", "SHA"] +uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" + +[[deps.LibGit2_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll"] +uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5" +version = "1.6.4+0" + +[[deps.LibSSH2_jll]] +deps = ["Artifacts", "Libdl", "MbedTLS_jll"] +uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" +version = "1.11.0+1" + +[[deps.Libdl]] +uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" + +[[deps.LinearAlgebra]] +deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] +uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" + +[[deps.Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" + +[[deps.LoggingExtras]] +deps = ["Dates", "Logging"] +git-tree-sha1 = "c1dd6d7978c12545b4179fb6153b9250c96b0075" +uuid = "e6f89c97-d47a-5376-807f-9c37f3926c36" +version = "1.0.3" + +[[deps.Lz4_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "6c26c5e8a4203d43b5497be3ec5d4e0c3cde240a" +uuid = "5ced341a-0733-55b8-9ab6-a4889d929147" +version = "1.9.4+0" + +[[deps.Markdown]] +deps = ["Base64"] +uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" + +[[deps.MbedTLS]] +deps = ["Dates", "MbedTLS_jll", "MozillaCACerts_jll", "NetworkOptions", "Random", "Sockets"] +git-tree-sha1 = "c067a280ddc25f196b5e7df3877c6b226d390aaf" +uuid = "739be429-bea8-5141-9913-cc70e7f3736d" +version = "1.1.9" + +[[deps.MbedTLS_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" +version = "2.28.2+1" + +[[deps.Missings]] +deps = ["DataAPI"] +git-tree-sha1 = "f66bdc5de519e8f8ae43bdc598782d35a25b1272" +uuid = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28" +version = "1.1.0" + +[[deps.Mmap]] +uuid = "a63ad114-7e13-5084-954f-fe012c677804" + +[[deps.Mocking]] +deps = ["Compat", "ExprTools"] +git-tree-sha1 = "4cc0c5a83933648b615c36c2b956d94fda70641e" +uuid = "78c3b35d-d492-501b-9361-3d52fe80e533" +version = "0.7.7" + +[[deps.MozillaCACerts_jll]] +uuid = "14a3606d-f60d-562e-9121-12d972cd8159" +version = "2023.1.10" + +[[deps.NetworkOptions]] +uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" +version = "1.2.0" + +[[deps.OpenBLAS_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] +uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" +version = "0.3.23+4" + +[[deps.OpenSSL]] +deps = ["BitFlags", "Dates", "MozillaCACerts_jll", "OpenSSL_jll", "Sockets"] +git-tree-sha1 = "af81a32750ebc831ee28bdaaba6e1067decef51e" +uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c" +version = "1.4.2" + +[[deps.OpenSSL_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "60e3045590bd104a16fefb12836c00c0ef8c7f8c" +uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" +version = "3.0.13+0" + +[[deps.OrderedCollections]] +git-tree-sha1 = "dfdf5519f235516220579f949664f1bf44e741c5" +uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +version = "1.6.3" + +[[deps.Parameters]] +deps = ["OrderedCollections", "UnPack"] +git-tree-sha1 = "34c0e9ad262e5f7fc75b10a9952ca7692cfc5fbe" +uuid = "d96e819e-fc66-5662-9728-84c9c7592b0a" +version = "0.12.3" + +[[deps.Parsers]] +deps = ["Dates", "PrecompileTools", "UUIDs"] +git-tree-sha1 = "8489905bcdbcfac64d1daa51ca07c0d8f0283821" +uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" +version = "2.8.1" + +[[deps.Pkg]] +deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"] +uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +version = "1.10.0" + +[[deps.PooledArrays]] +deps = ["DataAPI", "Future"] +git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3" +uuid = "2dfb63ee-cc39-5dd5-95bd-886bf059d720" +version = "1.4.3" + +[[deps.PrecompileTools]] +deps = ["Preferences"] +git-tree-sha1 = "5aa36f7049a63a1528fe8f7c3f2113413ffd4e1f" +uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +version = "1.2.1" + +[[deps.Preferences]] +deps = ["TOML"] +git-tree-sha1 = "9306f6085165d270f7e3db02af26a400d580f5c6" +uuid = "21216c6a-2e73-6563-6e65-726566657250" +version = "1.4.3" + +[[deps.PrettyTables]] +deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "Reexport", "StringManipulation", "Tables"] +git-tree-sha1 = "88b895d13d53b5577fd53379d913b9ab9ac82660" +uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" +version = "2.3.1" + +[[deps.Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" + +[[deps.REPL]] +deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] +uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" + +[[deps.Random]] +deps = ["SHA"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" + +[[deps.Reexport]] +git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" +uuid = "189a3867-3050-52da-a836-e630ba90ab69" +version = "1.2.2" + +[[deps.Rembus]] +deps = ["ArgParse", "Arrow", "Base64", "CSV", "DataFrames", "DataStructures", "Dates", "Distributed", "DocStringExtensions", "FileWatching", "HTTP", "JSON3", "Logging", "MbedTLS", "Parameters", "PrecompileTools", "Preferences", "Printf", "Random", "Reexport", "Serialization", "Sockets", "URIs", "UUIDs", "Visor", "ZMQ"] +path = ".." +uuid = "aa126574-2ec3-42c9-8bf5-7f62001d6bce" +version = "0.1.0" + +[[deps.SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[deps.Scratch]] +deps = ["Dates"] +git-tree-sha1 = "3bac05bc7e74a75fd9cba4295cde4045d9fe2386" +uuid = "6c6a2e73-6563-6170-7368-637461726353" +version = "1.2.1" + +[[deps.SentinelArrays]] +deps = ["Dates", "Random"] +git-tree-sha1 = "0e7508ff27ba32f26cd459474ca2ede1bc10991f" +uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c" +version = "1.4.1" + +[[deps.Serialization]] +uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" + +[[deps.SimpleBufferStream]] +git-tree-sha1 = "874e8867b33a00e784c8a7e4b60afe9e037b74e1" +uuid = "777ac1f9-54b0-4bf8-805c-2214025038e7" +version = "1.1.0" + +[[deps.Sockets]] +uuid = "6462fe0b-24de-5631-8697-dd941f90decc" + +[[deps.SortingAlgorithms]] +deps = ["DataStructures"] +git-tree-sha1 = "66e0a8e672a0bdfca2c3f5937efb8538b9ddc085" +uuid = "a2af1166-a08f-5f64-846c-94a0d3cef48c" +version = "1.2.1" + +[[deps.SparseArrays]] +deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"] +uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" +version = "1.10.0" + +[[deps.Statistics]] +deps = ["LinearAlgebra", "SparseArrays"] +uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +version = "1.10.0" + +[[deps.StringManipulation]] +deps = ["PrecompileTools"] +git-tree-sha1 = "a04cabe79c5f01f4d723cc6704070ada0b9d46d5" +uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" +version = "0.3.4" + +[[deps.StructTypes]] +deps = ["Dates", "UUIDs"] +git-tree-sha1 = "ca4bccb03acf9faaf4137a9abc1881ed1841aa70" +uuid = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" +version = "1.10.0" + +[[deps.SuiteSparse_jll]] +deps = ["Artifacts", "Libdl", "libblastrampoline_jll"] +uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" +version = "7.2.1+1" + +[[deps.TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" + +[[deps.TZJData]] +deps = ["Artifacts"] +git-tree-sha1 = "b69f8338df046774bd975b13be9d297eca5efacb" +uuid = "dc5dba14-91b3-4cab-a142-028a31da12f7" +version = "1.1.0+2023d" + +[[deps.TableTraits]] +deps = ["IteratorInterfaceExtensions"] +git-tree-sha1 = "c06b2f539df1c6efa794486abfb6ed2022561a39" +uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c" +version = "1.0.1" + +[[deps.Tables]] +deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "OrderedCollections", "TableTraits"] +git-tree-sha1 = "cb76cf677714c095e535e3501ac7954732aeea2d" +uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" +version = "1.11.1" + +[[deps.Tar]] +deps = ["ArgTools", "SHA"] +uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" +version = "1.10.0" + +[[deps.Test]] +deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] +uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[[deps.TextWrap]] +git-tree-sha1 = "9250ef9b01b66667380cf3275b3f7488d0e25faf" +uuid = "b718987f-49a8-5099-9789-dcd902bef87d" +version = "1.0.1" + +[[deps.TimeZones]] +deps = ["Artifacts", "Dates", "Downloads", "InlineStrings", "LazyArtifacts", "Mocking", "Printf", "Scratch", "TZJData", "Unicode", "p7zip_jll"] +git-tree-sha1 = "89e64d61ef3cd9e80f7fc12b7d13db2d75a23c03" +uuid = "f269a46b-ccf7-5d73-abea-4c690281aa53" +version = "1.13.0" + + [deps.TimeZones.extensions] + TimeZonesRecipesBaseExt = "RecipesBase" + + [deps.TimeZones.weakdeps] + RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" + +[[deps.TranscodingStreams]] +git-tree-sha1 = "a09c933bebed12501890d8e92946bbab6a1690f1" +uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" +version = "0.10.5" +weakdeps = ["Random", "Test"] + + [deps.TranscodingStreams.extensions] + TestExt = ["Test", "Random"] + +[[deps.URIs]] +git-tree-sha1 = "67db6cc7b3821e19ebe75791a9dd19c9b1188f2b" +uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" +version = "1.5.1" + +[[deps.UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" + +[[deps.UnPack]] +git-tree-sha1 = "387c1f73762231e86e0c9c5443ce3b4a0a9a0c2b" +uuid = "3a884ed6-31ef-47d7-9d2a-63182c4928ed" +version = "1.0.2" + +[[deps.Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" + +[[deps.Visor]] +deps = ["DataStructures", "Dates", "Logging", "UUIDs"] +git-tree-sha1 = "232783e703febab4ccb5e651fd562fbf673df8ff" +uuid = "cf786855-3531-4b86-ba6e-3e33dce7dcdb" +version = "0.6.0" + +[[deps.WeakRefStrings]] +deps = ["DataAPI", "InlineStrings", "Parsers"] +git-tree-sha1 = "b1be2855ed9ed8eac54e5caff2afcdb442d52c23" +uuid = "ea10d353-3f73-51f8-a26c-33c1cb351aa5" +version = "1.4.2" + +[[deps.WorkerUtilities]] +git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7" +uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60" +version = "1.6.1" + +[[deps.ZMQ]] +deps = ["FileWatching", "Sockets", "ZeroMQ_jll"] +git-tree-sha1 = "356d2bdcc0bce90aabee1d1c0f6d6f301eda8f77" +uuid = "c2297ded-f4af-51ae-bb23-16f91089e4e1" +version = "1.2.2" + +[[deps.ZeroMQ_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "libsodium_jll"] +git-tree-sha1 = "42f97fb27394378591666ab0e9cee369e6d0e1f9" +uuid = "8f1865be-045e-5c20-9c9f-bfbfb0764568" +version = "4.3.5+0" + +[[deps.Zlib_jll]] +deps = ["Libdl"] +uuid = "83775a58-1f1d-513f-b197-d71354ab007a" +version = "1.2.13+1" + +[[deps.Zstd_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "49ce682769cd5de6c72dcf1b94ed7790cd08974c" +uuid = "3161d3a3-bdf6-5164-811a-617609db77b4" +version = "1.5.5+0" + +[[deps.libblastrampoline_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" +version = "5.8.0+1" + +[[deps.libsodium_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] +git-tree-sha1 = "848ab3d00fe39d6fbc2a8641048f8f272af1c51e" +uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8" +version = "1.0.20+0" + +[[deps.nghttp2_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" +version = "1.52.0+1" + +[[deps.p7zip_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" +version = "17.4.0+2" diff --git a/examples/Project.toml b/examples/Project.toml new file mode 100644 index 0000000..cb69970 --- /dev/null +++ b/examples/Project.toml @@ -0,0 +1,3 @@ +[deps] +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +Rembus = "aa126574-2ec3-42c9-8bf5-7f62001d6bce" diff --git a/examples/subscriber.jl b/examples/subscriber.jl new file mode 100755 index 0000000..914ef07 --- /dev/null +++ b/examples/subscriber.jl @@ -0,0 +1,46 @@ +using DataFrames +using Rembus + +# A custom structure where announcements are stored +# just to demonstrate the strong symbiosis between Rembus and +# the dataframe concept it is used a Julia DataFrame. +mutable struct Announcements + df::DataFrame + Announcements() = new(DataFrame(:user => [], :message => [])) +end + +# A sort of global context to share state between exposed and subscribed methods. +const ANN_DB = Announcements() + +# This get called for each message received on topic "announcement" +function announcement(ann_db, user, message) + @info "news from [$user]: $message" + push!(ann_db.df, [user, message]) +end + +# This get called for each RPC request. +# Return a Dataframe object. +all_announcements(ann_db) = return ann_db.df + +# Naming a component is optional, you can comment the following line. +# A component without a name became a sort of "anonymous" entity that +# assumes an ephemeral identity each time it connects to the broker. +@component "organizer" + +# Subscribe to the topic "announcement". the option before_now declare interest +# in messages published in time intervals when the component was offline. +@subscribe announcement before_now + +# If a component is "anonymous", the above @component line commented out, then the +# messages published when the component is offline get lost even if @subscribe +# use the option before_now. + +# Shared object between all subscribers and exposers methods. +# When declared it get used as the first argument of the exposed/subscribed methods. +@shared ANN_DB + +# Expose the method that returns the announcements dataframe +@expose all_announcements + +# await forever for client requests or Ctrl-C termination command. +forever() diff --git a/src/Rembus.jl b/src/Rembus.jl new file mode 100644 index 0000000..2bff79e --- /dev/null +++ b/src/Rembus.jl @@ -0,0 +1,2017 @@ +#= +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# +module Rembus + +using ArgParse +using Arrow +using Base64 +using CSV +using DocStringExtensions +using DataFrames +using Dates +using DataStructures +using FileWatching +using HTTP +using JSON3 +using Logging +using MbedTLS +using Random +using Reexport +using Sockets +using Parameters +using PrecompileTools +using Preferences +using Printf +using URIs +using Serialization +using UUIDs +@reexport using Visor +using ZMQ + + +export @component +export @enable_ack, @disable_ack +export @expose, @unexpose +export @subscribe, @unsubscribe +export @rpc +export @publish +export @reactive, @unreactive +export @shared +export @rpc_timeout +export @terminate + +# rembus client api +export connect +export expose, unexpose +export subscribe, unsubscribe +export direct +export rpc +export publish +export reactive, unreactive +export enable_ack, disable_ack +export authorize, unauthorize +export private_topic, public_topic +export provide +export admin +export close +export enable_debug, disable_debug +export isconnected +export rembus +export setting +export shared +export set_balancer +export forever + +# broker api +export caronte, session, context + +export RembusError +export RembusTimeout +export RembusDisconnect +export RpcMethodNotFound, RpcMethodUnavailable, RpcMethodLoopback, RpcMethodException + +include("configuration.jl") +include("constants.jl") +include("logger.jl") +include("cbor.jl") +include("encode.jl") +include("decode.jl") +include("protocol.jl") +include("broker.jl") +include("transport.jl") +include("admin.jl") +include("store.jl") +include("register.jl") + +function __init__() + Visor.setroot(intensity=3) + atexit(shutdown) +end + +struct ConnectionClosed <: Exception +end + +# A message error received from the broker. +abstract type RembusException <: Exception end + +# An error response from the broker that is not one of: +# STS_METHOD_NOT_FOUND, STS_METHOD_EXCEPTION, STS_METHOD_LOOPBACK, STS_METHOD_UNAVAILABLE +Base.@kwdef struct RembusError <: RembusException + code::UInt8 + cid::Union{String,Nothing} = nothing + topic::Union{String,Nothing} = nothing + reason::Union{String,Nothing} = nothing +end + +""" +`RpcMethodNotFound` is thrown from a rpc request when a remote method is unknown. + +fields: +$(FIELDS) + +## RPC Client +```julia +@rpc coolservice() +``` +Output: +``` +ERROR: Rembus.RpcMethodNotFound("rembus", "coolservice") +Stacktrace: +... +``` +""" +struct RpcMethodNotFound <: RembusException + "component name" + cid::String + "service name" + topic::String +end + +""" + RpcMethodUnavailable + +Thrown when a RPC method is unavailable. + +A method is considered unavailable when some component that expose the method is +currently disconnected from the broker. + +# Fields +$(FIELDS) +""" +struct RpcMethodUnavailable <: RembusException + "component name" + cid::String + "service name" + topic::String +end + +""" + RpcMethodLoopback + +Thrown when a RPC request to a locally exposed method. + +# Fields +$(FIELDS) +""" +struct RpcMethodLoopback <: RembusException + "component name" + cid::String + "service name" + topic::String +end + +""" + RpcMethodException + +Thrown when a RPC method throws an exception. + +# Fields +$(FIELDS) + +## Exposer +```julia +@expose foo(name::AbstractString) = "hello " * name +``` +## RPC client +```julia +try + @rpc foo(1) +catch e + @error e.reason +end +``` +Output: +``` +┌ Error: MethodError: no method matching foo(::UInt64) +│ +│ Closest candidates are: +│ foo(!Matched::AbstractString) +│ @ Main REPL[2]:1 +└ @ Main REPL +``` +""" +struct RpcMethodException <: RembusException + "component name" + cid::String + "service name" + topic::String + "remote exception description" + reason::String +end + +""" + RembusTimeout + +Thrown when a response it is not received. +""" +struct RembusTimeout <: RembusException + msg::String + RembusTimeout(msg) = new(msg) +end + +""" + RembusDisconnect + +Thrown when a rembus connection get unexpectedly down. +""" +struct RembusDisconnect <: RembusException +end + +# Workaround lock for HTTP.WebSockets methods (close and send) +# Under heavy concurrent messaging data corruption may happens. +websocketlock = ReentrantLock() + +function rembuserror(raise::Bool=true; code, cid=nothing, topic=nothing, reason=nothing) + if code == STS_METHOD_NOT_FOUND + err = RpcMethodNotFound(cid, topic) + elseif code == STS_METHOD_EXCEPTION + err = RpcMethodException(cid, topic, reason) + elseif code == STS_METHOD_LOOPBACK + err = RpcMethodLoopback(cid, topic) + elseif code == STS_METHOD_UNAVAILABLE + err = RpcMethodUnavailable(cid, topic) + else + err = RembusError(code=code, cid=cid, topic=topic, reason=reason) + end + + if raise + throw(err) + else + return err + end +end + +struct Component + id::String + protocol::Symbol + host::String + port::UInt16 + props::Dict{String,String} + + function Component(url::String) + baseurl = get(ENV, "REMBUS_BASE_URL", "ws://127.0.0.1:8000") + baseuri = URI(baseurl) + uri = URI(url) + props = queryparams(uri) + + host = uri.host + if host == "" + host = baseuri.host + end + + portstr = uri.port + if portstr == "" + portstr = baseuri.port + end + + port = parse(UInt16, portstr) + + proto = uri.scheme + if proto == "" + name = uri.path + protocol = Symbol(baseuri.scheme) + elseif proto in ["ws", "wss", "tcp", "tls", "zmq"] + name = startswith(uri.path, "/") ? uri.path[2:end] : uri.path + protocol = Symbol(proto) + else + error("wrong url $url: unknown protocol $proto") + end + if isempty(name) + name = "rembus" + end + return new(name, protocol, host, port, props) + end +end + +brokerurl(c::Component) = "$(c.protocol == :zmq ? :tcp : c.protocol)://$(c.host):$(c.port)" + +struct CastCall + topic::String + data::Any +end + +Base.show(io::IO, call::CastCall) = print(io, call.topic) + +abstract type RBHandle end + +mutable struct RBConnection <: RBHandle + shared::Any + socket::Any + reactive::Bool + client::Component + receiver::Dict{String,Function} + out::Dict{UInt128,Condition} + context::Union{Nothing,ZMQ.Context} + RBConnection(name::String) = new( + missing, nothing, false, Component(name), Dict(), Dict(), nothing + ) + RBConnection(client=getcomponent()) = new( + missing, nothing, false, client, Dict(), Dict(), nothing + ) +end + +Base.isless(rb1::RBConnection, rb2::RBConnection) = length(rb1.out) < length(rb2.out) + +function Base.show(io::IO, rb::RBConnection) + return print(io, "client [$(rb.client.id)], isconnected: $(isconnected(rb))") +end + +mutable struct RBPool <: RBHandle + last_invoked::Dict{String,Int} # topic => index of last used connection + connections::Vector{RBConnection} + RBPool(conns::Vector{RBConnection}=[]) = new(Dict(), conns) +end + +keystore_dir() = get(ENV, "REMBUS_KEYSTORE", joinpath(get(ENV, "HOME", "."), "keystore")) + +request_timeout() = parse(Float32, get(ENV, "REMBUS_TIMEOUT", "5")) + +getcomponent() = Component(Rembus.CONFIG.cid) + +function name2proc(name::AbstractString, startproc=false, setanonymous=false) + return name2proc(Component(name), startproc, setanonymous) +end + +function name2proc(cmp::Component, startproc=false, setanonymous=false) + proc = from(cmp.id) + if proc === nothing + if setanonymous && CONFIG.cid == "rembus" + proc = startup(rembus()) + else + throw(Visor.UnknownProcess(cmp.id)) + end + end + + if startproc && !isdefined(proc, :task) + Visor.startchain(proc) + end + + return proc +end + +""" + @component "url" + +Set the name of the component and the protocol for connecting +to the broker. + +`url` may be: +- "myname": use \\\$REMBUS\\_BASE\\_URL for connection parameters +- "tcp://host:port/myname": tcp connection +- "ws://host:port/myname": web socket connection +- "zmq://host:port/myname": ZeroMQ connection +""" +macro component(name) + quote + Rembus.CONFIG.cid = $(esc(name)) + Visor.startup(rembus()) + end +end + +""" + @terminate + +Close the connection and terminate the component. +""" +macro terminate(name=getcomponent()) + quote + shutdown(name2proc($(esc(name)))) + Rembus.CONFIG.cid = "rembus" + nothing + end +end + +""" + @rpc_timeout value + +Set the rpc request timeout in seconds. +""" +macro rpc_timeout(value) + quote + ENV["REMBUS_TIMEOUT"] = $(esc(value)) + end +end + +macro rembus(cid=nothing) + quote + startup(rembus($(esc(cid)))) + end +end + +function holder_expr(shared, cid=getcomponent()) + ex = :(call( + Rembus.name2proc("cid", false, false), + Rembus.SetHolder(aaa), + timeout=Rembus.request_timeout() + )) + ex.args[3].args[2] = shared + ex.args[2].args[2] = cid + ex +end + +""" + @shared container + +Bind a `container` object that is passed as the first argument of the subscribed +component functions. + +The `container` is useful for mantaining a state. + +```julia +using Rembus + +# keep the number of processed messages +mutable struct Context + msgcount::UInt +end + +function topic(context::Context, arg1, arg2) + context.msgcount += 1 + some_logic(arg1, arg2) +end + +ctx = Context(0) +@subscribe topic +@shared ctx +@reactive +``` + +Using `@shared` to set a `container` object means that if some component +`publish topic(arg1,arg2)` then the method `foo(container,arg2,arg2)` will be called. + +""" +macro shared(container) + ex = holder_expr(container) + quote + $(esc(ex)) + nothing + end +end + +macro shared(cid, container) + ex = holder_expr(container, cid) + quote + $(esc(ex)) + nothing + end +end + +function publish_expr(topic, cid=getcomponent()) + ext = :(cast(Rembus.name2proc("cid", true, true), Rembus.CastCall(t, []))) + + fn = string(topic.args[1]) + ext.args[2].args[2] = cid + ext.args[3].args[2] = fn + + args = topic.args[2:end] + ext.args[3].args[3].args = args + ext +end + +""" + @publish topic(arg1,arg2,...) + +Publish a message to `topic` logic channel. + +The function `topic(arg1,arg2,...)` will be called on each connected component subscribed +to `topic`. + +## Publisher +```julia +@publish foo("gfr", 54.2) +``` + +## Subscriber +```julia +function foo(name, value) + println("do something with \$name=\$value") +end + +@subscribe foo +@reactive + +supervise() +``` +""" +macro publish(topic) + ext = publish_expr(topic) + quote + $(esc(ext)) + end +end + +macro publish(cid, topic) + ext = publish_expr(topic, cid) + quote + $(esc(ext)) + end +end + +function rpc_expr(topic, cid=getcomponent()) + ext = :(call( + Rembus.name2proc("cid", true, true), + Rembus.CastCall(t, []), + timeout=Rembus.request_timeout() + )) + fn = string(topic.args[1]) + ext.args[2].args[2] = cid + ext.args[3].args[2] = fn + + args = topic.args[2:end] + ext.args[3].args[3].args = args + ext +end + +""" + @rpc service(arg1,...) + +Call the remote `service` method and return its outcome. + +The outcome may be the a return value or a [`RpcMethodException`](@ref) if the remote +throws an exception. + +The `service` method must match the signature of an exposed remote `service` method. + +Components may subscribe to `service` for receiving the `service` request. + +## Exposer +```julia +function mymethod(x, y) + return evaluate(x,y) +end + +@expose mymethod +supervise() +``` + +## RPC client +```julia +response = @rpc mymethod(x,y) +``` + +## Subscriber +```julia +function service(x, y) + ... +end + +@subscribe service +@reactive + +supervise() + +``` +""" +macro rpc(topic) + ext = rpc_expr(topic) + quote + $(esc(ext)) + end +end + +macro rpc(cid, topic) + ext = rpc_expr(topic, cid) + quote + $(esc(ext)) + end +end + +fnname(fn::Expr) = fn.args[1].args[1] +fnname(fn::Symbol) = fn + +function expose_expr(fn, cid=getcomponent()) + ex = :(call( + Rembus.name2proc("cid", true, true), + Rembus.AddImpl(aaa), + timeout=Rembus.request_timeout() + )) + ex.args[3].args[2] = fnname(fn) + ex.args[2].args[2] = cid + ex +end + +function subscribe_expr(fn, mode::Symbol, cid=getcomponent()) + if mode == :from_now + sts = false + elseif mode == :before_now + sts = true + else + return :(throw( + ErrorException("subscribe invalid mode: must be from_now or before_now") + )) + end + ex = :(call( + Rembus.name2proc(Rembus.getcomponent(), true, true), + Rembus.AddInterest(aaa, $sts), + timeout=Rembus.request_timeout() + )) + ex.args[3].args[2] = fnname(fn) + ex.args[2].args[2] = cid + ex +end + +""" + @expose fn + +Expose all the methods of the function `fn`. + +## Example + +Expose the function `mycalc` that implements a service that may accept two numbers or a +string and number: + +```julia +mycalc(x::Number, y::Number) = x+y +mycalc(x::String, y::Number) = length(x)*y + +@expose mycalc +``` +Call `mycal` service using the correct types of arguments: + +```julia +# ok +julia> response = @rpc mycalc(1,2) +0x0000000000000003 + +# ok +julia> response = @rpc mycalc("hello",2.0) +10.0 +``` + +If the RPC client call `mycalc` with the argument's type that +do not respect the signatures of the exposed service +then it throws [`RpcMethodException`](@ref) + +```julia +julia> response = @rpc mycalc("hello","world") +ERROR: RpcMethodException("rembus", "mycalc", "MethodError: no method matching \ +mycalc(::String, ::String) ... +``` +""" +macro expose(fn::Symbol) + ex = expose_expr(fn) + quote + $(esc(ex)) + nothing + end +end + +""" + @expose function fn(arg1,...) + ... + end + +Expose the function expression. +""" +macro expose(fn::Expr) + ex = expose_expr(fn) + quote + $(esc(fn)) + $(esc(ex)) + nothing + end +end + +macro expose(cid, fn::Symbol) + ex = expose_expr(fn, cid) + quote + $(esc(ex)) + nothing + end +end + +macro expose(cid, fn::Expr) + ex = expose_expr(fn, cid) + quote + $(esc(fn)) + $(esc(ex)) + nothing + end +end + +""" + @subscribe topic [mode] + +Setup a subscription to `topic` logic channel to handle messages from [`@publish`](@ref) +or [`@rpc`](@ref). + +`mode` values`: +- `from_now` (default): receive messages published from now. +- `before_now`: receive messages published when the component was offline. + +Messages starts to be delivered to `topic` when reactivity is enabled with `@reactive` +macro. + +## Subscriber +```julia +function foo(arg1, arg2) + ... +end + +@subscribe foo +@reactive + +supervise() +``` + +## Publisher +```julia +@publish foo("gfr", 54.2) +``` +""" +macro subscribe(fn::Symbol, mode::Symbol=:from_now) + ex = subscribe_expr(fn, mode) + quote + $(esc(ex)) + nothing + end +end + +""" + @subscribe function fn(args...) + ... + end [mode] + +Subscribe the function expression. +""" +macro subscribe(fn::Expr, mode::Symbol=:from_now) + ex = subscribe_expr(fn, mode) + quote + $(esc(fn)) + $(esc(ex)) + nothing + end +end + +macro subscribe(cid, fn::Expr, mode::Symbol=:from_now) + ex = subscribe_expr(fn, mode, cid) + quote + $(esc(fn)) + $(esc(ex)) + nothing + end +end + +macro subscribe(cid, fn::Symbol, mode::Symbol=:from_now) + ex = subscribe_expr(fn, mode, cid) + quote + $(esc(ex)) + nothing + end +end + +""" + @unexpose fn + +The methods of `fn` function is no more available to rpc clients. +""" +macro unexpose(fn::Symbol) + :(@unexpose getcomponent() $(esc(fn))) +end + +macro unexpose(cid, fn) + ex = :(call( + Rembus.name2proc("cid"), + Rembus.RemoveImpl(aaa), + timeout=Rembus.request_timeout() + )) + ex.args[3].args[2] = fn + ex.args[2].args[2] = cid + quote + $(esc(ex)) + nothing + end +end + +""" + @unsubscribe mytopic + +The methods of `mytopic` function stop to handle messages +published to topic `mytopic`. +""" +macro unsubscribe(fn::Symbol) + :(@unsubscribe getcomponent() $(esc(fn))) +end + +macro unsubscribe(cid, fn) + ex = :(call( + Rembus.name2proc("cid"), + Rembus.RemoveInterest(aaa), + timeout=Rembus.request_timeout() + )) + ex.args[3].args[2] = fn + ex.args[2].args[2] = cid + quote + $(esc(ex)) + nothing + end +end + +function reactive_expr(reactive, cid=nothing) + if cid === nothing + id = getcomponent() + else + id = cid + end + ex = :(call( + Rembus.name2proc("cid"), + Rembus.Reactive($reactive), + timeout=Rembus.request_timeout() + )) + ex.args[2].args[2] = id + ex +end + +function enable_ack_expr(enable, cid=nothing) + if cid === nothing + id = getcomponent() + else + id = cid + end + ex = :(call( + Rembus.name2proc("cid"), + Rembus.EnableAck($enable), + timeout=Rembus.request_timeout() + )) + ex.args[2].args[2] = id + ex +end + +""" + @reactive + +The subscribed methods start to handle published messages. +""" +macro reactive(cid=nothing) + ex = reactive_expr(true, cid) + quote + $(esc(ex)) + nothing + end +end + +""" + @unreactive + +The subscribed methods stop to handle published messages. +""" +macro unreactive(cid=nothing) + ex = reactive_expr(false, cid) + quote + $(esc(ex)) + nothing + end +end + +""" + @enable_ack + +Enable acknowledge receipt of published messages. + +This feature assure that messages get delivered at least one time to the +subscribed component. + +For default the acknowledge is disabled. +""" +macro enable_ack(cid=nothing) + ex = enable_ack_expr(true, cid) + quote + $(esc(ex)) + nothing + end +end + +""" + @disable_ack + +Disable acknowledge receipt of published messages. + +This feature assure that messages get delivered at least one to the +subscribed component. +""" +macro disable_ack(cid=nothing) + ex = disable_ack_expr(false, cid) + quote + $(esc(ex)) + nothing + end +end + +struct SetHolder + shared::Any +end + +struct AddImpl + fn::Function +end + +struct RemoveImpl + fn::Function +end + +struct AddInterest + fn::Function + retroactive::Bool +end + +struct RemoveInterest + fn::Function +end + +struct Reactive + status::Bool +end +struct EnableAck + status::Bool +end + +function provide(server::Embedded, func::Function) + server.topic_function[string(func)] = func +end + +""" + shared(rb::RBHandle, ctx) + +Bind a `ctx` context object to the `rb` component. + +When a `ctx` context object is bound then it will be the first argument of subscribed and +exposed methods. +""" +shared(rb::RBHandle, ctx) = rb.shared = ctx + +function rembus(cid=nothing) + if cid === nothing + id = Rembus.CONFIG.cid + else + id = cid + end + + cmp = Component(id) + + if haskey(cmp.props, "server") + caronte(wait=false, exit_when_done=false) + + while true + proc = from("caronte.serve_zeromq") + sleep(0.2) + if proc !== nothing && proc.status === Visor.running + break + end + end + end + + rb = RBConnection(cmp) + process( + cmp.id, + rembus_task, + args=(rb, cmp.protocol), + debounce_time=CONFIG.connection_retry_period, + force_interrupt_after=3.0) +end + +function rembus_task(pd, rb, protocol=:ws) + try + @debug "starting rembus process: $pd, protocol:$protocol" + + connect(pd, rb) + for msg in pd.inbox + @debug "[$pd] recv: $msg" + if isshutdown(msg) + return + elseif isa(msg, Exception) + if isa(msg, ConnectionClosed) && isconnected(rb) + @debug "[$pd] ignoring connection closed message" + continue + end + @info "[$pd] rembus task: $msg" + throw(msg) + elseif isrequest(msg) + req = msg.request + if isa(req, SetHolder) + result = shared(rb, msg.request.shared) + elseif isa(req, AddImpl) + result = expose( + rb, string(msg.request.fn), msg.request.fn, exceptionerror=false + ) + elseif isa(req, RemoveImpl) + result = unexpose(rb, string(msg.request.fn), exceptionerror=false) + elseif isa(req, AddInterest) + result = subscribe( + rb, + string(msg.request.fn), + msg.request.fn, + msg.request.retroactive, + exceptionerror=false + ) + elseif isa(req, RemoveInterest) + result = unsubscribe(rb, string(msg.request.fn), exceptionerror=false) + elseif isa(req, Reactive) + if req.status + result = reactive(rb, exceptionerror=false) + else + result = unreactive(rb, exceptionerror=false) + end + else + result = rpc( + rb, msg.request.topic, msg.request.data, exceptionerror=false + ) + end + reply(msg, result) + else + publish(rb, msg.topic, msg.data) + end + end + catch e + if isa(e, HTTP.Exceptions.ConnectError) + msg = "[$pd]: $(e.url) connection error" + else + msg = "[$pd]: $e" + end + @error msg + @showerror e + rethrow() + finally + @debug "[$pd]: terminating" + close(rb) + end +end + +mutable struct NullProcess <: Visor.Supervised + id::String + inbox::Channel + NullProcess(id) = new(id, Channel(1)) +end + +add_receiver(ctx, method_name, impl) = ctx.receiver[method_name] = impl + +remove_receiver(ctx, method_name) = delete!(ctx.receiver, method_name) + +function when_connected(fn, rb) + while !isconnected(rb) + sleep(1) + end + fn() +end + +#= + invoke(rb::RBConnection, topic::AbstractString, msg::RembusMsg) + +Invoke the method registered with `topic` name. +=# +function invoke(rb::RBConnection, topic::AbstractString, msg::RembusMsg) + if isa(msg.data, Vector) + if rb.shared === missing + return STS_SUCCESS, rb.receiver[topic](msg.data...) + else + return STS_SUCCESS, rb.receiver[topic](rb.shared, msg.data...) + end + else + if rb.shared === missing + return STS_SUCCESS, rb.receiver[topic](msg.data) + else + return STS_SUCCESS, rb.receiver[topic](rb.shared, msg.data) + end + end +end + +#= + invoke_latest(rb::RBConnection, topic::AbstractString, msg::RembusMsg) + +Invoke the method registered with `topic` name using `Base.invokelatest`. +=# +function invoke_latest(rb::RBConnection, topic::AbstractString, msg::RembusMsg) + if isa(msg.data, Vector) + if rb.shared === missing + return STS_SUCCESS, Base.invokelatest(rb.receiver[topic], msg.data...) + else + return ( + STS_SUCCESS, Base.invokelatest(rb.receiver[topic], rb.shared, msg.data...) + ) + end + else + if rb.shared === missing + return STS_SUCCESS, Base.invokelatest(rb.receiver[topic], msg.data) + else + return STS_SUCCESS, Base.invokelatest(rb.receiver[topic], rb.shared, msg.data) + end + end +end + +#= + invoke_glob(rb::RBConnection, topic::AbstractString, msg::RembusMsg) + +Invoke the method registered with `*` name for received messages with any topic. +=# +function invoke_glob(rb::RBConnection, msg::RembusMsg) + if isa(msg.data, Vector) + if rb.shared === missing + return STS_SUCCESS, rb.receiver["*"](msg.topic, msg.data...) + else + return STS_SUCCESS, rb.receiver["*"](rb.shared, msg.topic, msg.data...) + end + else + if rb.shared === missing + return STS_SUCCESS, rb.receiver["*"](msg.topic, msg.data) + else + return STS_SUCCESS, rb.receiver["*"](rb.shared, msg.topic, msg.data) + end + end +end + +function rembus_handler(rb, msg, receiver) + fn::String = msg.topic + if haskey(rb.receiver, fn) + try + return receiver(rb, fn, msg) + catch e + @showerror e + io = IOBuffer() + showerror(io, e) + return STS_METHOD_EXCEPTION, String(take!(io)) + end + elseif haskey(rb.receiver, "*") + try + invoke_glob(rb, msg) + return STS_SUCCESS, nothing + catch e + rethrow() + end + end + # no response: it is a broadcasted message or a published message + return STS_SUCCESS, nothing +end + +function handle_input(rb, msg) + #@debug "<< [$(rb.client.id)] <- $msg" + + if isresponse(msg) + if haskey(rb.out, msg.id) + # prevent timeout because when jit compiling + # notify() may be called before wait() + yield() + + while notify(rb.out[msg.id], msg) == 0 + @info "$msg: notifying too early" + sleep(0.0001) + end + else + # it is a response without a waiting Condition + if msg.data === nothing + @async ping(rb) + elseif msg.status == STS_CHALLENGE + @async resend_attestate(rb, msg) + else + @warn "ignoring response: $msg" + end + end + else + if isinteractive() + sts, result = rembus_handler(rb, msg, invoke_latest) + else + sts, result = rembus_handler(rb, msg, invoke) + end + + if sts === STS_METHOD_EXCEPTION + @warn "[$(msg.topic)]: $result" + end + + if isa(msg, RpcReqMsg) + response = ResMsg(msg.id, sts, result) + @debug "response: $response" + transport_send(rb.socket, response) + elseif isa(msg, PubSubMsg) && (msg.flags & 0x80) == 0x80 + # check if ack is enabled + # @debug "$msg sending Ack with hash=$(msg.hash)" + transport_send(rb.socket, AckMsg(msg.hash)) + end + end + + return nothing +end + +function trim_msg(msg) + if length(msg) > 500 + return "$(first(msg, 500)) ..." + else + return msg + end +end + +function parse_msg(rb, response) + try + msg = connected_socket_load(response) + handle_input(rb, msg) + catch e + @error "parse_msg: $e\n" + @showerror e + end + + return nothing +end + +keep_alive(socket::TCPSocket) = nothing + +function keep_alive(socket::WebSockets.WebSocket) + CONFIG.ws_ping_interval == 0 && return + + while true + sleep(CONFIG.ws_ping_interval) + if isopen(socket.io) + @debug "socket ping" + ping(socket) + else + @debug "socket connection closed, keep alive done" + break + end + end +end + +isok(sock::HTTP.WebSockets.WebSocket, e) = HTTP.WebSockets.isok(e) +isok(sock, e) = false + +processput!(process::NullProcess, e) = nothing +processput!(process::Visor.Process, e) = put!(process.inbox, e) + +function read_socket(socket, process, rb, isconnected::Condition) + try + rb.socket = socket + # signal to the initiator function _connect that the connection is up. + notify(isconnected) + + # enable connection alive watchdog + @async keep_alive(rb.socket) + while isopen(socket) + response = transport_read(socket) + if !isempty(response) + @async parse_msg(rb, response) + else + @debug "[$(rb.client.id)] connection closed" + end + end + @info "[$process] socket closed" + catch e + @debug "[$(rb.client.id)] connection closed: $e" + if !isa(e, HTTP.WebSockets.WebSocketError) || + !isa(e.message, HTTP.WebSockets.CloseFrameBody) || + e.message.status != 1000 + @showerror e + processput!(process, e) + end + end +end + +function ws_connect(rb, process, isconnected::Condition) + try + if !haskey(ENV, "HTTP_CA_BUNDLE") + trust_store = keystore_dir() + ca_file = get(ENV, "REMBUS_CA", "rembus-ca.crt") + ENV["HTTP_CA_BUNDLE"] = joinpath(trust_store, ca_file) + end + + url = brokerurl(rb.client) + + if startswith(url, "wss:") + HTTP.WebSockets.open(socket -> begin + read_socket(socket, process, rb, isconnected) + end, url) + else + HTTP.WebSockets.open(socket -> begin + ## Sockets.nagle(socket.io.io, false) + ## Sockets.quickack(socket.io.io, true) + read_socket(socket, process, rb, isconnected) + end, url, idle_timeout=1, forcenew=true) + end + catch e + notify(isconnected, e, error=true) + @showerror e + end +end + +function zmq_receive(rb) + while true + try + msg = zmq_load(rb.socket) + handle_input(rb, msg) + catch e + if !isopen(rb.socket) + break + else + @error "[zmq_receive] error: $e" + @showerror e + end + end + end + @debug "zmq socket closed" +end + +function zmq_connect(rb) + rb.context = ZMQ.Context() + rb.socket = ZMQ.Socket(rb.context, DEALER) + rb.socket.linger = 1 + try + url = brokerurl(rb.client) + ZMQ.connect(rb.socket, url) + @async zmq_receive(rb) + + CONFIG.zmq_ping_interval > 0 && Timer(tmr -> ping(rb), CONFIG.zmq_ping_interval) + catch e + @showerror e + close(rb.socket) + close(rb.context) + rethrow() + end + + return nothing +end + +function tcp_connect(rb, process, isconnected::Condition) + try + trust_store = keystore_dir() + ca_file = get(ENV, "REMBUS_CA", "rembus-ca.crt") + + url = brokerurl(rb.client) + uri = URI(url) + + cacert = get(ENV, "HTTP_CA_BUNDLE", joinpath(trust_store, ca_file)) + + @debug "connecting to $(uri.scheme):$(uri.host):$(uri.port)" + if uri.scheme == "tls" + entropy = MbedTLS.Entropy() + rng = MbedTLS.CtrDrbg() + MbedTLS.seed!(rng, entropy) + + ctx = MbedTLS.SSLContext() + + sslconf = MbedTLS.SSLConfig(true) + MbedTLS.config_defaults!(sslconf) + + MbedTLS.rng!(sslconf, rng) + + MbedTLS.ca_chain!(sslconf, MbedTLS.crt_parse(read(cacert, String))) + + function show_debug(level, filename, number, msg) + println((level, filename, number, msg)) + end + + MbedTLS.dbg!(sslconf, show_debug) + + sock = Sockets.connect(uri.host, parse(Int, uri.port)) + MbedTLS.setup!(ctx, sslconf) + MbedTLS.set_bio!(ctx, sock) + MbedTLS.handshake(ctx) + + @async read_socket(ctx, process, rb, isconnected) + elseif uri.scheme == "tcp" + sock = Sockets.connect(uri.host, parse(Int, uri.port)) + @async read_socket(sock, process, rb, isconnected) + end + catch e + @error "tcp_connect: $e" + notify(isconnected, e, error=true) + end +end + +function pkfile(name) + cfgdir = joinpath(get(ENV, "HOME", "."), APP_CONFIG_DIR, "rembus") + if !isdir(cfgdir) + mkpath(cfgdir) + end + + return joinpath(cfgdir, name) +end + +function loadkey(name::AbstractString) + file = pkfile(name) + @debug "keyfile: $file" + if isfile(file) + return MbedTLS.parse_keyfile(file) + end + + return missing +end + +function resend_attestate(rb, response) + try + msg = attestate(rb, response) + rembus_write(rb, msg) + catch e + @error "resend_attestate: $e" + @showerror e + end + + return nothing +end + +function attestate(rb, response) + file = pkfile(rb.client.id) + if !isfile(file) + error("unable to find $(rb.client.id) secret") + end + + try + ctx = MbedTLS.parse_keyfile(file) + plain = encode([Vector{UInt8}(response.data), rb.client.id]) + hash = MbedTLS.digest(MD_SHA256, plain) + signature = MbedTLS.sign(ctx, MD_SHA256, hash, MersenneTwister(0)) + return Attestation(rb.client.id, signature) + catch e + if isa(e, MbedTLS.MbedException) + # try with a plain secret + secret = readline(file) + plain = encode([response.data, secret]) + hash = MbedTLS.digest(MD_SHA256, plain) + @debug "[$(rb.client.id)] digest: $hash" + return Attestation(rb.client.id, hash) + end + end +end + +function authenticate(rb) + if rb.client.id == "rembus" + return nothing + end + + reason = nothing + msg = IdentityMsg(rb.client.id) + response = wait_response(rb, msg, request_timeout()) + + if isa(response, RembusTimeout) + close(rb.socket) + throw(response) + elseif (response.status == STS_CHALLENGE) + msg = attestate(rb, response) + response = wait_response(rb, msg, request_timeout()) + end + + if (response.status != STS_SUCCESS) + # Avoid DOS attack: this has to be the server work!! + close(rb.socket) + rembuserror(code=response.status, reason=reason) + end + + return nothing +end + +function _connect(rb, process) + if rb.client.protocol === :ws || rb.client.protocol === :wss + isconnected = Condition() + @async ws_connect(rb, process, isconnected) + wait(isconnected) + elseif rb.client.protocol === :tcp || rb.client.protocol === :tls + isconnected = Condition() + @async tcp_connect(rb, process, isconnected) + wait(isconnected) + elseif rb.client.protocol === :zmq + zmq_connect(rb) + else + throw(ErrorException( + "wrong protocol $(rb.client.protocol): must be tcp|tls|zmq|ws|wss" + )) + end + + return rb +end + +function ping(socket) + try + WebSockets.ping(socket) + catch e + @error "socket ping: $e" + end + + return nothing +end + +function rembus_write(rb::RBHandle, msg) + @debug ">> [$(rb.client.id)] -> $msg" + transport_send(rb.socket, msg) + return nothing +end + +function rembus_block_write(rb::RBHandle, msg, cond) + @debug ">> [$(rb.client.id)] -> $msg" + transport_send(rb.socket, msg) + wait(cond) + return nothing +end + +function configure(rb::RBHandle, retroactives=Dict(), interests=Dict(), impls=Dict()) + for (topic, fn) in retroactives + subscribe(rb, topic, fn, true) + end + for (topic, fn) in interests + subscribe(rb, topic, fn, false) + end + for (topic, fn) in impls + expose(rb, topic, fn) + end + + return rb +end + +function isconnected(rb::RBConnection) + if rb.socket === nothing + false + else + if isa(rb.socket, WebSockets.WebSocket) + return isopen(rb.socket.io) + elseif isa(rb.socket, TCPSocket) + return !( + rb.socket.status === Base.StatusClosed || + rb.socket.status === Base.StatusEOF + ) + elseif isa(rb.socket, ZMQ.Socket) + return isopen(rb.socket) + else + return !( + rb.socket.bio.status === Base.StatusClosed || + rb.socket.bio.status === Base.StatusEOF + ) + end + end +end + +isconnected(rb::RBPool) = any(c -> isconnected(c), rb.connections) + +function connect(rb::RBConnection) + if !isconnected(rb) + _connect(rb, NullProcess(rb.client.id)) + authenticate(rb) + end + + return rb +end + +""" + connect() + +Connect anonymously to the broker. + +A random v4 UUID is used as component identifier. +""" +function connect() + rb = RBConnection() + return connect(rb) +end + +""" + connect(url::AbstractString)::RBHandle + +Connect to the broker. + +The returned rembus handler do not auto-reconnect in case of a fault condition. + +The returned `RBHandle` handle represents a connected component +used for the Rembus APIs. For example: + +```julia +using Rembus +rb = connect("mycomponent") +publish(rb, "temperature", ["room_1", 21.5]) +``` +""" +function connect(url::AbstractString)::RBHandle + process = NullProcess(url) + rb = RBConnection(process.id) + return connect(rb) +end + +#= + connect(process::Visor.Supervised, rb::RBHandle) + +Connect the component defined by the `rb` handle to the broker. + +The supervised task `process` receives an `Exception` message when +an exception is thrown by the `read_socket()`. + +The `process` supervisor try to auto-reconnect if an exception occurs. +=# +function connect(process::Visor.Supervised, rb::RBHandle) + _connect(rb, process) + authenticate(rb) + if rb.reactive + reactive(rb) + end + + return rb +end + +function connect(rb::RBPool) + for c in rb.connections + try + connect(c) + catch e + if isa(e, RembusError) + @warn "error: $e" + else + @warn "connection failed: $(e.url)" + end + end + end + + return rb +end + +function connect(urls::Vector) + pool = RBPool([RBConnection(url) for url in urls]) + return connect(pool) +end + +function login(rb::RBHandle, cid::AbstractString, secret::AbstractString) + try + challenge = rpc(rb, "challenge") + attestation = MbedTLS.digest(MbedTLS.MD_SHA256, encode([challenge, secret])) + @debug "[$cid] digest: $attestation" + rpc(rb, "login", [cid, attestation]) || error("invalid password") + catch e + error("login failed: $e") + end + + return nothing +end + +function Base.close(rb::RBHandle) + try + if rb.socket !== nothing + if isa(rb.socket, ZMQ.Socket) + transport_send(rb.socket, Close()) + close(rb.context) + else + lock(websocketlock) do + close(rb.socket) + end + end + end + catch e + @warn "[$(rb.client.id)] close: $e" + end + + return nothing +end + +function assert_rembus(process::Visor.Process) + if length(process.args) == 0 || !isa(process.args[1], RBHandle) + throw(ErrorException("invalid $process process: not a rembus process")) + end +end + +function enable_debug(rb::RBHandle) + return rpcreq(rb, AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => ENABLE_DEBUG_CMD))) +end + +function disable_debug(rb::RBHandle) + return rpcreq(rb, AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => DISABLE_DEBUG_CMD))) +end + +function broker_config(rb::RBHandle; exceptionerror=true) + return rpcreq(rb, + AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => BROKER_CONFIG_CMD)), + exceptionerror=exceptionerror + ) +end + +function load_config(rb::RBHandle; exceptionerror=true) + return rpcreq(rb, + AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => LOAD_CONFIG_CMD)), + exceptionerror=exceptionerror + ) +end + +function save_config(rb::RBHandle; exceptionerror=true) + return rpcreq(rb, + AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => SAVE_CONFIG_CMD)), + exceptionerror=exceptionerror + ) +end + +function disable_ack(rb::RBHandle; exceptionerror=true) + return rpcreq(rb, + AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => DISABLE_ACK_CMD)), + exceptionerror=exceptionerror + ) +end + +function enable_ack(rb::RBHandle, timeout=5; exceptionerror=true) + return rpcreq(rb, + AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => ENABLE_ACK_CMD)), + exceptionerror=exceptionerror + ) +end + +""" + unreactive(rb::RBHandle, timeout=5; exceptionerror=true) + +Stop the delivery of published message. +""" +function unreactive(rb::RBHandle; exceptionerror=true) + response = rpcreq(rb, + AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => REACTIVE_CMD, STATUS => false)), + exceptionerror=exceptionerror + ) + rb.reactive = false + + return response +end + +""" + reactive(rb::RBHandle, timeout=5; exceptionerror=true) + +Start the delivery of published messages for which there was declared +an interest with [`subscribe`](@ref). +""" +function reactive(rb::RBHandle; exceptionerror=true) + response = rpcreq(rb, + AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => REACTIVE_CMD, STATUS => true)), + exceptionerror=exceptionerror + ) + rb.reactive = true + + return response +end + +""" + subscribe( + rb::RBHandle, topic::AbstractString, fn::Function, retroactive::Bool=false; + exceptionerror=true + ) + +Declare interest for messages published on `topic`. + +The function `fn` is called when a message is received on `topic` and +[`reactive`](@ref) put the `rb` component in reactive mode. + +If `retroactive` is `true` then `rb` component will receive messages published when it was +offline. +""" +function subscribe( + rb::RBHandle, topic::AbstractString, fn::Function, retroactive::Bool=false; + exceptionerror=true +) + add_receiver(rb, topic, fn) + return rpcreq(rb, + AdminReqMsg(topic, Dict(COMMAND => ADD_INTEREST_CMD, RETROACTIVE => retroactive)), + exceptionerror=exceptionerror + ) +end + +""" + unsubscribe(rb::RBHandle, topic::AbstractString; exceptionerror=true) + +No more messages published on `topic` will be delivered to `rb` component. +""" +function unsubscribe(rb::RBHandle, topic::AbstractString; exceptionerror=true) + remove_receiver(rb, topic) + return rpcreq(rb, + AdminReqMsg(topic, Dict(COMMAND => REMOVE_INTEREST_CMD)), + exceptionerror=exceptionerror + ) +end + +""" + expose(rb::RBHandle, topic::AbstractString, fn::Function; exceptionerror=true) + +The methods of function `fn` are registered to be executed when +a RPC `topic` request is received. + +The returned value is the RPC response returned to the RPC client. +""" +function expose(rb::RBHandle, topic::AbstractString, fn::Function; exceptionerror=true) + add_receiver(rb, topic, fn) + return rpcreq(rb, + AdminReqMsg(topic, Dict(COMMAND => ADD_IMPL_CMD)), + exceptionerror=exceptionerror + ) +end + +""" + unexpose(rb::RBHandle, topic::AbstractString; exceptionerror=true) + +Stop servicing RPC `topic` request. +""" +function unexpose(rb::RBHandle, topic::AbstractString; exceptionerror=true) + remove_receiver(rb, topic) + return rpcreq(rb, + AdminReqMsg(topic, Dict(COMMAND => REMOVE_IMPL_CMD)), + exceptionerror=exceptionerror + ) +end + +function private_topic(rb::RBHandle, topic::AbstractString; exceptionerror=true) + return rpcreq(rb, + AdminReqMsg(topic, Dict(COMMAND => PRIVATE_TOPIC_CMD)), + exceptionerror=exceptionerror + ) +end + +function public_topic(rb::RBHandle, topic::AbstractString; exceptionerror=true) + return rpcreq(rb, + AdminReqMsg(topic, Dict(COMMAND => PUBLIC_TOPIC_CMD)), + exceptionerror=exceptionerror + ) +end + +function authorize( + rb::RBHandle, client::AbstractString, topic::AbstractString; + exceptionerror=true +) + return rpcreq(rb, + AdminReqMsg(topic, Dict(COMMAND => AUTHORIZE_CMD, CID => client)), + exceptionerror=exceptionerror + ) +end + +function unauthorize( + rb::RBHandle, client::AbstractString, topic::AbstractString; + exceptionerror=true +) + return rpcreq(rb, + AdminReqMsg(topic, Dict(COMMAND => UNAUTHORIZE_CMD, CID => client)), + exceptionerror=exceptionerror + ) +end + +# """ +# ping(rb::RBHandle) +# +# Send a ping message to check if the broker is online. +# +# Required by ZeroMQ socket. +# """ +function ping(rb::RBHandle) + try + rpcreq(rb, PingMsg(rb.client.id)) + CONFIG.zmq_ping_interval > 0 && Timer(tmr -> ping(rb), CONFIG.zmq_ping_interval) + catch e + @debug "[$(rb.client.id)]: pong not received" + @showerror e + end + + return nothing +end + +""" + publish(rb::RBHandle, topic::AbstractString, data=[]) + +Publish `data` values on topic `topic`. + +`data` may be any type of data, but if the components are implemented in different languages +then data has to be a DataFrame or a [CBOR](https://www.rfc-editor.org/rfc/rfc8949.html) +basic data type. +""" +function publish(rb::RBHandle, topic::AbstractString, data=[]) + return rembus_write(rb, PubSubMsg(topic, data)) +end + +""" + rpc(rb::RBHandle, + topic::AbstractString, + data=nothing; + exceptionerror=true, + timeout=request_timeout()) + +Call the remote `topic` method with arguments extracted from `data`. + +## Exposer + +```julia +using Rembus +using Statistics + +@expose service_noargs() = "success" + +@expose service_name(name) = "hello " * name + +@expose service_dictionary(d) = mean(values(d)) + +@expose function service_multiple_args(name, score, flags) + isa(name, String) && isa(score, Float64) && isa(flags, Vector) +end +``` + +## RPC client + +```julia +using Rembus + +rb = connect() + +rcp(rb, "service_noargs") + +rpc(rb, "service_name", "hello world") + +rpc(rb, "service_dictionary", Dict("r1"=>13.3, "r2"=>3.0)) + +rpc(rb, "service_multiple_args", ["name", 1.0, ["red"=>1,"blue"=>2,"yellow"=>3]]) +``` +""" +function rpc(rb::RBHandle, topic::AbstractString, data=[]; + exceptionerror=true, timeout=request_timeout()) + rpcreq(rb, RpcReqMsg(topic, data), exceptionerror=exceptionerror, timeout=timeout) +end + +function direct( + rb::RBHandle, target::AbstractString, topic::AbstractString, data=nothing; + exceptionerror=false +) + return rpcreq(rb, RpcReqMsg(topic, data, target), exceptionerror=exceptionerror) +end + +function response_timeout(condition::Condition, msg::RembusMsg) + if hasproperty(msg, :topic) + descr = "[$(msg.topic)]: request timeout" + else + descr = "[$msg]: request timeout" + end + notify(condition, RembusTimeout(descr), error=false) + + return nothing +end + +# https://github.com/JuliaLang/julia/issues/36217 +function wait_response(rb::RBHandle, msg::RembusMsg, timeout) + mid::UInt128 = msg.id + resp_cond = Condition() + rb.out[mid] = resp_cond + t = Timer((tim) -> response_timeout(resp_cond, msg), timeout) + # @async ensures that wait is always triggered before notify + rembus_write(rb, msg) + try + return wait(resp_cond) + catch e + @debug "[$msg]: response timeout ($e)" + rethrow() + finally + close(t) + delete!(rb.out, mid) + end +end + +# Send a RpcReqMsg message to rembus and return the response. +function rpcreq(handle::RBHandle, msg; exceptionerror=true, timeout=request_timeout()) + outcome = nothing + !isconnected(handle) && error("connection is down") + + if isa(handle, RBPool) + if CONFIG.balancer === "first_up" + rb = first_up(handle, msg.topic, handle.connections) + elseif CONFIG.balancer === "round_robin" + rb = round_robin(handle, msg.topic, handle.connections) + else + rb = less_busy(handle, msg.topic, handle.connections) + end + else + rb = handle + end + + response = wait_response(rb, msg, timeout) + if isa(response, RembusTimeout) + outcome = response + if exceptionerror + throw(outcome) + end + elseif response.status == STS_SUCCESS + outcome = response.data + else + outcome = rembuserror(exceptionerror, code=response.status, + cid=rb.client.id, + topic=msg.topic, + reason=response.data) + if exceptionerror + throw(outcome) + end + end + + return outcome +end + +function broker_shutdown() + admin = connect("admin") + rpcreq(admin, AdminReqMsg("__config__", Dict(COMMAND => SHUTDOWN_CMD))) +end + +function forever() + process = from(CONFIG.cid) + if process !== nothing + cmp = process.args[1] + reactive(cmp) + # Don't block the REPL! + isinteractive() ? nothing : supervise() + end +end + +@setup_workload begin + ENV["REMBUS_ZMQ_PING_INTERVAL"] = "0" + ENV["REMBUS_WS_PING_INTERVAL"] = "0" + @compile_workload begin + sv = Rembus.caronte(wait=false, exit_when_done=false) + Rembus.islistening(10) + include("precompile.jl") + shutdown() + end +end + +end # module diff --git a/src/admin.jl b/src/admin.jl new file mode 100644 index 0000000..7251ac9 --- /dev/null +++ b/src/admin.jl @@ -0,0 +1,212 @@ +""" + private_topic(router, twin, msg) + +Administration command to declare a private topic. +""" +function private_topic(router, twin, msg) + sts = STS_SUCCESS + if isadmin(router, twin, PRIVATE_TOPIC_CMD) + callback_and(Symbol(PRIVATE_TOPIC_CMD), router, twin, msg) do + if !haskey(router.topic_auth, msg.topic) + router.topic_auth[msg.topic] = Dict() + end + end + else + sts = STS_GENERIC_ERROR + end + return sts +end + +""" + public_topic(router, twin, msg) + +Administration command to reset a topic to public. +""" +function public_topic(router, twin, msg) + sts = STS_SUCCESS + if isadmin(router, twin, PUBLIC_TOPIC_CMD) + callback_and(Symbol(PUBLIC_TOPIC_CMD), router, twin, msg) do + delete!(router.topic_auth, msg.topic) + end + else + sts = STS_GENERIC_ERROR + end + return sts +end + +""" + authorize(router, twin, msg) + +Administration command to authorize a component to publish/subscribe to a private topic. +""" +function authorize(router, twin, msg) + sts = STS_SUCCESS + if isadmin(router, twin, AUTHORIZE_CMD) && + haskey(msg.data, CID) && + !isempty(msg.data[CID]) + callback_and(Symbol(AUTHORIZE_CMD), router, twin, msg) do + if !haskey(router.topic_auth, msg.topic) + router.topic_auth[msg.topic] = Dict() + end + router.topic_auth[msg.topic][msg.data[CID]] = true + end + else + sts = STS_GENERIC_ERROR + end + + return sts +end + +""" + unauthorize(router, twin, msg) + +Administration command to unauthorize a component to publish/subscribe to a private topic. +""" +function unauthorize(router, twin, msg) + sts = STS_SUCCESS + if isadmin(router, twin, UNAUTHORIZE_CMD) && + haskey(msg.data, CID) && + !isempty(msg.data[CID]) + callback_and(Symbol(UNAUTHORIZE_CMD), router, twin, msg) do + if haskey(router.topic_auth, msg.topic) + delete!(router.topic_auth[msg.topic], msg.data[CID]) + end + end + else + sts = STS_GENERIC_ERROR + end + + return sts +end + +function admin_command(router, twin, msg::AdminReqMsg) + if !isa(msg.data, Dict) || !haskey(msg.data, COMMAND) + return AdminResMsg(msg.id, STS_GENERIC_ERROR, nothing) + end + + sts = STS_SUCCESS + data = nothing + cmd = msg.data[COMMAND] + if cmd == ADD_INTEREST_CMD + if isauth(router, twin, msg.topic) + callback_and(Symbol(ADD_INTEREST_CMD), router, twin, msg) do + retroactive = get(msg.data, RETROACTIVE, true) + twin.retroactive[msg.topic] = retroactive + if haskey(router.topic_interests, msg.topic) + push!(router.topic_interests[msg.topic], twin) + else + router.topic_interests[msg.topic] = Set([twin]) + end + end + else + sts = STS_GENERIC_ERROR + end + elseif cmd == ADD_IMPL_CMD + if isauth(router, twin, msg.topic) + callback_and(Symbol(ADD_IMPL_CMD), router, twin, msg) do + if haskey(router.topic_impls, msg.topic) + push!(router.topic_impls[msg.topic], twin) + else + router.topic_impls[msg.topic] = Set([twin]) + end + end + else + sts = STS_GENERIC_ERROR + end + elseif cmd == REMOVE_INTEREST_CMD + if isauth(router, twin, msg.topic) + callback_and(Symbol(REMOVE_INTEREST_CMD), router, twin, msg) do + if haskey(router.topic_interests, msg.topic) + if twin in router.topic_interests[msg.topic] + delete!(router.topic_interests[msg.topic], twin) + else + sts = STS_GENERIC_ERROR + end + # remove from twin configuration + if haskey(twin.retroactive, msg.topic) + delete!(twin.retroactive, msg.topic) + end + else + sts = STS_GENERIC_ERROR + end + end + else + sts = STS_GENERIC_ERROR + end + elseif cmd == REMOVE_IMPL_CMD + if isauth(router, twin, msg.topic) + callback_and(Symbol(REMOVE_IMPL_CMD), router, twin, msg) do + if haskey(router.topic_impls, msg.topic) + if twin in router.topic_impls[msg.topic] + delete!(router.topic_impls[msg.topic], twin) + else + sts = STS_GENERIC_ERROR + end + else + sts = STS_GENERIC_ERROR + end + end + else + sts = STS_GENERIC_ERROR + end + elseif cmd == TOPICS_CONFIG_CMD + # only admins is authorized + if isadmin(router, twin, cmd) + data = Dict() + for (topic, cids) in router.topic_auth + data[topic] = collect(keys(cids)) + end + else + sts = STS_GENERIC_ERROR + end + elseif cmd == PRIVATE_TOPIC_CMD + sts = private_topic(router, twin, msg) + elseif cmd == PUBLIC_TOPIC_CMD + sts = public_topic(router, twin, msg) + elseif cmd == AUTHORIZE_CMD + sts = authorize(router, twin, msg) + elseif cmd == UNAUTHORIZE_CMD + sts = unauthorize(router, twin, msg) + elseif cmd == REACTIVE_CMD + enabled = get(msg.data, STATUS, false) + if enabled + start_reactive(twin) + else + twin.reactive = false + end + elseif cmd === ENABLE_ACK_CMD + twin.qos = with_ack + elseif cmd === DISABLE_ACK_CMD + twin.qos = fast + elseif cmd === BROKER_CONFIG_CMD + data = router_configuration(router) + elseif cmd === LOAD_CONFIG_CMD + # first save data to disk and then return the configuration + load_configuration(router) + elseif cmd === SAVE_CONFIG_CMD + # first save data to disk and then return the configuration + save_configuration(router) + elseif cmd === RESET_ROUTER_CMD + empty!(router.topic_impls) + empty!(router.topic_interests) + elseif cmd == SHUTDOWN_CMD + @debug "shutting down ..." + sts = STS_SHUTDOWN + try + Visor.shutdown(router.process.supervisor) + catch e + @error "$SHUTDOWN_CMD: $e" + end + elseif cmd == ENABLE_DEBUG_CMD + CONFIG.debug_modules = [Rembus, Visor] + elseif cmd == DISABLE_DEBUG_CMD + CONFIG.debug_modules = [] + elseif cmd == UPTIME_CMD + data = uptime(router) + else + @error "invalid admin command: $cmd" + sts = STS_UNKNOWN_ADMIN_CMD + end + + return ResMsg(msg.id, sts, data) +end diff --git a/src/broker.jl b/src/broker.jl new file mode 100644 index 0000000..d9edae9 --- /dev/null +++ b/src/broker.jl @@ -0,0 +1,1781 @@ +#= +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +@enum QoS fast with_ack + +abstract type AbstractRouter end + +#= +Twin is the broker-side image of a component. + +`sock` is the socket handle when the protocol is tcp/tls or ws/wss. + +For ZMQ sockets one socket is shared between all twins. +=# +mutable struct Twin + router::AbstractRouter + id::String + session::Dict{String,Any} + hasname::Bool + isauth::Bool + sock::Any + retroactive::Dict{String,Bool} + mq::Queue{PubSubMsg} + out::Channel # router inbox + sent::Dict{UInt128,Any} # msg.id => timestamp of sending + acktimer::Dict{UInt128,Timer} + qos::QoS + mfile::Union{IOStream,Nothing} + reactive::Bool + process::Visor.Process + + Twin(router, id, out_channel) = new( + router, + id, + Dict(), + false, + false, + nothing, + Dict(), + Queue{PubSubMsg}(), + out_channel, + Dict(), + Dict(), + fast, + nothing, + true, + ) + Twin(router, id, queue, out_channel) = new( + router, + id, + Dict(), + false, + false, + nothing, + Dict(), + queue, + out_channel, + Dict(), + Dict(), + fast, + nothing, + true, + ) +end + +mutable struct Msg + ptype::UInt8 + content::RembusMsg + twchannel::Twin + reqdata::Any + Msg(ptype, content, src) = new(ptype, content, src) + Msg(ptype, content, src, reqdata) = new(ptype, content, src, reqdata) +end + +struct SentData + sending_ts::Float64 + request::Msg +end + +mutable struct Embedded <: AbstractRouter + topic_function::Dict{String,Function} + id_twin::Dict{String,Twin} + process::Visor.Process + ws_server::Sockets.TCPServer + Embedded() = new( + Dict(), + Dict(), + ) +end + +mutable struct Router <: AbstractRouter + start_ts::Float64 + address2twin::Dict{Vector{UInt8},Twin} # zeromq address => twin + twin2address::Dict{String,Vector{UInt8}} # twin id => zeromq address + mid2address::Dict{UInt128,Vector{UInt8}} # message.id => zeromq connection address + topic_impls::Dict{String,OrderedSet{Twin}} # topic => twins implementor + last_invoked::Dict{String,Int} # topic => twin index last called + topic_interests::Dict{String,Set{Twin}} # topic => twins subscribed to topic + id_twin::Dict{String,Twin} + topic_function::Dict{String,Function} + topic_auth::Dict{String,Dict{String,Bool}} # topic => {twin.id => true} + admins::Set{String} + twin_initialize::Function + twin_finalize::Function + park::Function + unpark::Function + process::Visor.Process + server::Sockets.TCPServer + ws_server::Sockets.TCPServer + zmqsocket::ZMQ.Socket + owners::DataFrame + token_app::DataFrame + Router() = new( + time(), + Dict(), + Dict(), + Dict(), + Dict(), + Dict(), + Dict(), + Dict(), + Dict(), + Dict(), + Set(), + twin_initialize, + twin_finalize, + park, + unpark + ) +end + +Base.hash(t::Twin, h::UInt) = hash(t.id, hash(:Twin, h)) +Base.:(==)(a::Twin, b::Twin) = isequal(a.id, b.id) +Base.show(io::IO, t::Twin) = print(io, t.id) + +function Base.show(io::IO, msg::Msg) + if (isa(msg.content, ResMsg) || isa(msg.content, PubSubMsg)) && + isa(msg.content.data, Vector{UInt8}) + len = length(msg.content.data) + cnt = len > 10 ? msg.content.data[1:10] : msg.content.data + print(io, "binary[$len] $cnt ...") + else + print(io, "$(msg.content)") + end +end + +macro mlog(str) + quote + if CONFIG.metering + @info $(esc(str)) + end + end +end + +macro rawlog(msg) + quote + if CONFIG.rawdump + @info $(esc(msg)) + end + end +end + +## Twin related functions + +twin_initialize(ctx, twin) = (ctx, twin) -> () + +twin_finalize(ctx, twin) = (ctx, twin) -> () + +offline(twin::Twin) = ((twin.sock === nothing) || !isopen(twin.sock)) + +session(twin::Twin) = twin.session + +function create_twin(id, router::Embedded, queue=Queue{PubSubMsg}()) + if haskey(router.id_twin, id) + return router.id_twin[id] + else + twin = Twin(router, id, Channel()) + spec = process(id, twin_task, args=(twin,)) + startup(from("caronte.twins"), spec) + router.id_twin[id] = twin + return twin + end +end + +named_twin(id, router) = haskey(router.id_twin, id) ? router.id_twin[id] : nothing + +function create_twin(id, router, queue=Queue{PubSubMsg}()) + if haskey(router.id_twin, id) + return router.id_twin[id] + else + twin = Twin(router, id, queue, router.process.inbox) + spec = process(id, twin_task, args=(twin,)) + startup(from("caronte.twins"), spec) + router.id_twin[id] = twin + twin_initialize(CONFIG.broker_ctx, twin) + return twin + end +end + +#= + offline!(twin) + +Unbind the ZMQ socket from the twin. +=# +function offline!(twin) + @debug "[$twin] closing: going offline" + twin.sock = nothing + + return nothing +end + +#= + destroy_twin(twin, router) + +Remove the twin from the system. + +Shutdown the process and remove the twin from the router. +=# +function destroy_twin(twin, router) + if isdefined(twin, :process) + Visor.shutdown(twin.process) + end + + # Remove from address2twin + filter!(((k, v),) -> twin != v, router.address2twin) + + # Remove from topic_impls + for topic in keys(router.topic_impls) + delete!(router.topic_impls[topic], twin) + if isempty(router.topic_impls[topic]) + delete!(router.topic_impls, topic) + end + end + + # Remove from topic_interests + for topic in keys(router.topic_interests) + delete!(router.topic_interests[topic], twin) + if isempty(router.topic_interests[topic]) + delete!(router.topic_interests, topic) + end + end + + delete!(router.id_twin, twin.id) + return nothing +end + +function destroy_twin(twin, router::Embedded) + if isdefined(twin, :process) + Visor.shutdown(twin.process) + end + + delete!(router.id_twin, twin.id) + return nothing +end + + +#= + dequeue_messages(twin) + +Take the in-memory messages and send to the component. +=# +function dequeue_messages(twin) + while !isempty(twin.mq) && isopen(twin.sock.io) + if twin.qos === with_ack + io = transport_file_io(first(twin.mq)) + if write_waitack(twin.sock, io, 3) + popfirst!(twin.mq) + else + detach(twin) + end + elseif twin.qos === fast + transport_write(twin.sock, first(twin.mq)) + end + end + + return nothing +end + +function start_reactive(twin) + twin.reactive = true + # sends to real client + # all enqueued messages + @async twin.router.unpark(CONFIG.broker_ctx, twin) + return nothing +end + +function verify_signature(twin, msg) + challenge = pop!(twin.session, "challenge") + @debug "verify signature, challenge $challenge" + file = pubkey_file(msg.cid) + + try + ctx = MbedTLS.parse_public_keyfile(file) + plain = encode([challenge, msg.cid]) + hash = MbedTLS.digest(MD_SHA256, plain) + MbedTLS.verify(ctx, MD_SHA256, hash, msg.signature) + catch e + if isa(e, MbedTLS.MbedException) && + e.ret == MbedTLS.MBEDTLS_ERR_RSA_VERIFY_FAILED + rethrow() + end + # try with a plain secret + @debug "verify signature with password string" + secret = readline(file) + plain = encode([challenge, secret]) + digest = MbedTLS.digest(MbedTLS.MD_SHA256, plain) + @debug "[$twin] digest plain: $plain" + if digest != msg.signature + error("authentication failed") + end + end + + return true +end + +#= + setidentity(router, twin, msg) + +Update twin identity parameters. +=# +function setidentity(router, twin, msg, isauth=false) + # get the eventually created twin associate with cid + namedtwin = create_twin(msg.cid, router) + # move the opened socket + namedtwin.sock = twin.sock + # destroy the anonymous process + destroy_twin(twin, router) + namedtwin.hasname = true + namedtwin.isauth = isauth + return namedtwin +end + +function login(router, twin, msg) + if haskey(router.topic_function, "login") + router.topic_function["login"](twin, msg.cid, msg.signature) || + error("authentication failed") + else + verify_signature(twin, msg) + end + + @debug "[$(msg.cid)] is authenticated" + return nothing +end + +#= + attestation(router, twin, msg, authenticate=true, ispong=false) + +Authenticate the client. + +If authentication fails then close the websocket. +=# +function attestation(router, twin, msg, authenticate=true) + @debug "[$twin] binding cid: $(msg.cid), authenticate: $authenticate" + sts = STS_SUCCESS + reason = nothing + authtwin = nothing + try + if authenticate + login(router, twin, msg) + end + authtwin = setidentity(router, twin, msg, authenticate) + catch e + @error "[$(msg.cid)] attestation: $e" + sts = STS_GENERIC_ERROR + reason = isa(e, ErrorException) ? e.msg : string(e) + end + + response = ResMsg(msg.id, sts, reason) + @mlog("[$twin] -> $response") + transport_send(twin, twin.sock, response) + + if sts !== STS_SUCCESS + detach(twin) + end + + return authtwin +end + +function attestation(router::Embedded, twin, msg) + @debug "[$twin] authenticating cid: $(msg.cid)" + sts = STS_SUCCESS + reason = nothing + try + login(router, twin, msg) + @debug "[$(msg.cid)] is authenticated" + twin.hasname = true + twin.isauth = true + catch e + @error "[$(msg.cid)] attestation: $e" + sts = STS_GENERIC_ERROR + reason = isa(e, ErrorException) ? e.msg : string(e) + end + + response = ResMsg(msg.id, sts, reason) + @mlog("[$twin] -> $response") + transport_send(twin, twin.sock, response) + + if sts !== STS_SUCCESS + detach(twin) + end + + return twin +end + +function get_token(router, userid, id::UInt128) + vals = UInt8[(id>>24)&0xff, (id>>16)&0xff, (id>>8)&0xff, id&0xff] + token = bytes2hex(vals) + df = router.owners[(router.owners.pin.==token).&(router.owners.uid.==userid), :] + if isempty(df) + @info "user [$userid]: invalid token" + return nothing + else + @debug "user [$userid]: token is valid" + return token + end +end + +#= + register(router, twin, msg) + +Register a client app. +=# +function register(router, twin, msg) + @debug "[$(twin.id)] registering pubkey of $(msg.cid), id: $(msg.id)" + sts = STS_SUCCESS + reason = nothing + token = get_token(router, msg.userid, msg.id) + if token === nothing + sts = STS_GENERIC_ERROR + reason = "wrong pin" + elseif twin.isauth + sts = STS_GENERIC_ERROR + reason = "already registered" + elseif isregistered(msg.cid) + sts = STS_NAME_ALREADY_TAKEN + reason = "name $(msg.cid) not available for registration" + else + try + save_pubkey(msg.cid, msg.pubkey) + if !(msg.cid in router.token_app.app) + push!(router.token_app, [msg.userid, msg.cid]) + end + save_token_app(router.token_app) + catch e + @error "[$(msg.cid)] register: $e" + sts = STS_GENERIC_ERROR + end + end + response = ResMsg(msg.id, sts, reason) + @mlog("[$twin] -> $response") + transport_send(twin, twin.sock, response) + +end + +#= + unregister(twin, msg) + +Unregister a client app. +=# +function unregister(router, twin, msg) + @debug "[$twin] unregistering $(msg.cid), isauth: $(twin.isauth)" + sts = STS_SUCCESS + reason = nothing + + if !twin.isauth + sts = STS_GENERIC_ERROR + reason = "invalid operation" + elseif twin.id != msg.cid + sts = STS_GENERIC_ERROR + reason = "invalid cid" + else + try + remove_pubkey(msg.cid) + deleteat!(router.token_app, router.token_app.app .== msg.cid) + save_token_app(router.token_app) + catch e + @error "[$(msg.cid)] unregister: $e" + sts = STS_GENERIC_ERROR + end + end + response = ResMsg(msg.id, sts, reason) + @mlog("[$twin] -> $response") + transport_send(twin, twin.sock, response) + +end + +# Response to an Rpc request +function rpc_response(router, twin, msg) + if haskey(twin.sent, msg.id) + twinput = twin.sent[msg.id].request.twchannel + reqdata = twin.sent[msg.id].request.content + put!(router.process.inbox, Msg(TYPE_RESPONSE, msg, twinput, reqdata)) + + elapsed = time() - twin.sent[msg.id].sending_ts + if CONFIG.metering + @debug "[$(twin.id)][$(reqdata.topic)] exec elapsed time: $elapsed secs" + end + + delete!(twin.sent, msg.id) + else + @error "[$twin] unexpected response: $msg" + end +end + +function admin_msg(router, twin, msg) + admin_res = admin_command(router, twin, msg) + @debug "admin response: $admin_res" + transport_send(twin, twin.sock, admin_res, true) + if admin_res.status === STS_SHUTDOWN + save_configuration(router) + end + + return nothing +end + +function embedded_msg(router::Embedded, twin::Twin, msg::RembusMsg) + (found, resmsg) = embedded_eval(router, twin, msg) + + if found + if isa(resmsg, ResMsg) + response = Msg(TYPE_RESPONSE, resmsg, twin) + respond(router, response) + end + else + @debug "[embedded] no provider for [$(msg.topic)]" + if isa(msg, RpcReqMsg) + response = Msg(TYPE_RESPONSE, ResMsg(msg, STS_METHOD_NOT_FOUND, nothing), twin) + respond(router, response) + end + end + + return nothing +end + +rpc_request(router::Embedded, twin, msg) = embedded_msg(router, twin, msg) + +pubsub_msg(router::Embedded, twin, msg) = embedded_msg(router, twin, msg) + +function rpc_request(router, twin, msg) + if !isauth(router, twin, msg.topic) + put!( + twin.process.inbox, + Msg(TYPE_RESPONSE, ResMsg(msg, STS_GENERIC_ERROR, "unauthorized"), twin) + ) + elseif msg.target !== nothing + # it is a direct rpc + if haskey(router.id_twin, msg.target) + target_twin = router.id_twin[msg.target] + if offline(target_twin) + put!( + twin.process.inbox, + Msg(TYPE_RESPONSE, ResMsg(msg, STS_TARGET_DOWN, msg.target), twin) + ) + else + put!(target_twin.process.inbox, Msg(TYPE_RPC, msg, twin)) + end + else + # target twin is unavailable + put!( + twin.process.inbox, + Msg(TYPE_RESPONSE, ResMsg(msg, STS_TARGET_NOT_FOUND, msg.target), twin) + ) + end + else + # msg is routable, get it to router + @debug "[$twin] to router: $(prettystr(msg))" + put!(router.process.inbox, Msg(TYPE_RPC, msg, twin, msg)) + end + + return nothing +end + +function pubsub_msg(router, twin, msg) + if !isauth(router, twin, msg.topic) + @warn "[$twin] is not authorized to publish on $(msg.topic)" + else + # msg is routable, get it to router + @debug "[$twin] to router: $(prettystr(msg))" + put!(router.process.inbox, Msg(TYPE_PUB, msg, twin)) + end + + return nothing +end + +function ack_msg(twin, msg) + if twin.qos === with_ack + msgid = msg.hash + if haskey(twin.acktimer, msgid) + close(twin.acktimer[msgid]) + delete!(twin.acktimer, msgid) + end + end + + return nothing +end + +function receiver_exception(router, twin, e) + if isconnectionerror(twin.sock, e) + if close_is_ok(twin.sock, e) + @debug "[$twin] connection closed" + else + @error "[$twin] connection closed: $e" + @showerror e + end + elseif isa(e, InterruptException) + rethrow() + elseif isa(e, ArgumentError) + @error "[$twin] invalid message format: $e" + @showerror e + else + @error "[$twin] internal error: $e" + @showerror e + end +end + +function end_receiver(twin) + if twin.hasname + detach(twin) + else + destroy_twin(twin, twin.router) + end +end + +#= + twin_receiver(router, twin) + +Receive messages from the client socket. +=# +function twin_receiver(router, twin) + @debug "client [$(twin.id)] is connected" + try + ws = twin.sock + while isopen(ws) + payload = transport_read(ws) + if isempty(payload) + twin.sock = nothing + @debug "client [$(twin.id)]: connection close" + break + end + msg::RembusMsg = broker_parse(payload) + @mlog("[$(twin.id)] <- $(prettystr(msg))") + + if isa(msg, IdentityMsg) + error("identity: already authenticated") + elseif isa(msg, Register) + error("register: already authenticated") + elseif isa(msg, Unregister) + unregister(router, twin, msg) + elseif isa(msg, Attestation) + error("attestation: already authenticated") + elseif isa(msg, ResMsg) + rpc_response(router, twin, msg) + elseif isa(msg, AdminReqMsg) + admin_msg(router, twin, msg) + elseif isa(msg, RpcReqMsg) + rpc_request(router, twin, msg) + elseif isa(msg, PubSubMsg) + pubsub_msg(router, twin, msg) + elseif isa(msg, AckMsg) + ack_msg(twin, msg) + end + end + catch e + receiver_exception(router, twin, e) + @showerror e + finally + end_receiver(twin) + end + + return nothing +end + +function isauthenticated(session) + return haskey(session, "isauthenticated") && session["isauthenticated"] == true +end + +function challenge(router, twin, msg) + if isauthenticated(twin.session) + error("already authenticated") + elseif haskey(router.topic_function, "challenge") + challenge = router.topic_function["challenge"](twin) + else + challenge = rand(RandomDevice(), UInt8, 4) + end + twin.session["challenge"] = challenge + return ResMsg(msg.id, STS_CHALLENGE, challenge) +end + +#= + anonymous_twin_receiver(router, twin) + +Receive messages from the client socket. +=# +function anonymous_twin_receiver(router, twin) + @debug "anonymous client [$(twin.id)] is connected" + try + ws = twin.sock + while isopen(ws) + payload = transport_read(ws) + if isempty(payload) + twin.sock = nothing + @debug "client [$(twin.id)]: connection close" + break + end + msg::RembusMsg = broker_parse(payload) + @mlog("[$(twin.id)] <- $(prettystr(msg))") + + if isa(msg, IdentityMsg) + @debug "[$twin] auth identity: $(msg.cid)" + if isempty(msg.cid) + transport_send(twin, ws, ResMsg(msg.id, STS_GENERIC_ERROR, "empty cid")) + end + # close connection if a client with the same cid is already connected + named = named_twin(msg.cid, router) + if named !== nothing && !offline(named) + @warn "a component with id [$(msg.cid)] is already connected" + close(ws) + else + # check if cid is registered + rembus_login = isfile(joinpath(CONFIG.db, "apps", msg.cid)) + + if rembus_login + # authentication mode, send the challenge + response = challenge(router, twin, msg) + else + authtwin = setidentity(router, twin, msg) + transport_send(authtwin, ws, ResMsg(msg.id, STS_SUCCESS, nothing)) + return authtwin + end + @mlog("[$(twin.id)] -> $response") + transport_send(twin, ws, response) + end + elseif isa(msg, Register) + register(router, twin, msg) + elseif isa(msg, Unregister) + unregister(router, twin, msg) + elseif isa(msg, Attestation) + return attestation(router, twin, msg) + elseif isa(msg, ResMsg) + rpc_response(router, twin, msg) + elseif isa(msg, AdminReqMsg) + admin_msg(router, twin, msg) + elseif isa(msg, RpcReqMsg) + rpc_request(router, twin, msg) + elseif isa(msg, PubSubMsg) + pubsub_msg(router, twin, msg) + elseif isa(msg, AckMsg) + ack_msg(twin, msg) + end + end + catch e + receiver_exception(router, twin, e) + finally + end_receiver(twin) + end + + return nothing +end + +function zeromq_receiver(router::Router) + while true + try + pkt::ZMQPacket = zmq_message(router) + id = pkt.identity + + if haskey(router.address2twin, id) + twin = router.address2twin[id] + else + @debug "creating anonymous twin from identity $id ($(bytes2zid(id)))" + # create the twin + twin = create_twin(string(bytes2zid(id)), router) + @debug "[anonymous] client bound to twin id [$twin]" + router.address2twin[id] = twin + router.twin2address[twin.id] = id + twin.sock = router.zmqsocket + end + + msg::RembusMsg = broker_parse(router, pkt) + @mlog("[ZMQ][$twin] <- $(prettystr(msg))") + + if isa(msg, IdentityMsg) + @debug "[$twin] auth identity: $(msg.cid)" + # check if cid is registered + rembus_login = isfile(joinpath(CONFIG.db, "apps", msg.cid)) + if rembus_login + # authentication mode, send the challenge + response = challenge(router, twin, msg) + else + identity_upgrade(router, twin, msg, id, authenticate=false) + continue + end + @mlog("[ZMQ][$twin] -> $response") + transport_send(twin, router.zmqsocket, response) + elseif isa(msg, PingMsg) + if (twin.id != msg.cid) + # broker restarted + # start the authentication flow if cid is registered + @debug "lost connection to broker: restarting $(msg.cid)" + rembus_login = isfile(joinpath(CONFIG.db, "apps", msg.cid)) + if rembus_login + response = challenge(router, twin, msg) + transport_send(twin, router.zmqsocket, response) + else + identity_upgrade(router, twin, msg, id, authenticate=false) + end + + else + if twin.sock !== nothing + pong(twin.sock, msg.id, id) + + # check if there are cached messages + if twin.reactive + unpark_messages(CONFIG.broker_ctx, twin) + end + end + end + elseif isa(msg, Register) + register(router, twin, msg) + elseif isa(msg, Unregister) + unregister(router, twin, msg) + elseif isa(msg, Attestation) + identity_upgrade(router, twin, msg, id, authenticate=true) + elseif isa(msg, ResMsg) + rpc_response(router, twin, msg) + elseif isa(msg, AdminReqMsg) + admin_msg(router, twin, msg) + elseif isa(msg, RpcReqMsg) + rpc_request(router, twin, msg) + elseif isa(msg, PubSubMsg) + pubsub_msg(router, twin, msg) + elseif isa(msg, AckMsg) + ack_msg(twin, msg) + elseif isa(msg, Close) + offline!(twin) + elseif isa(msg, Remove) + destroy_twin(twin, router) + end + catch e + if isa(e, Visor.ProcessInterrupt) || isa(e, ZMQ.StateError) + rethrow() + end + @warn "[ZMQ] recv error: $e" + @showerror e + end + end +end + +function identity_upgrade(router, twin, msg, id; authenticate=false) + newtwin = attestation(router, twin, msg, authenticate) + if newtwin !== nothing + router.address2twin[id] = newtwin + delete!(router.twin2address, twin.id) + router.twin2address[newtwin.id] = id + end + + return nothing +end + +function close_is_ok(ws::WebSockets.WebSocket, e) + HTTP.WebSockets.isok(e) +end + +function close_is_ok(ws::TCPSocket, e) + isa(e, ConnectionClosed) +end + +close_is_ok(::Nothing, e) = true + +#= + detach(twin) + +Disconnect the twin from the ws/tcp channel. +=# +function detach(twin) + if twin.sock !== nothing + try + if !isa(twin.sock, ZMQ.Socket) + close(twin.sock) + end + catch e + @debug "error closing websocket: $e" + end + twin.sock = nothing + end + + return nothing +end + +#= + twin_task(self, twin) + +Twin task that read messages from router and send to client. + +It enqueues the input messages if the component is offline. +=# +function twin_task(self, twin) + twin.process = self + try + @debug "starting twin [$(twin.id)]" + for msg in self.inbox + if isshutdown(msg) + break + end + signal!(twin, msg) + end + catch e + @error "twin_task: $e" exception = (e, catch_backtrace()) + end + @debug "[$twin] task done" +end + +msghash(pkt) = UInt128(hash(pkt.topic)) + UInt128(hash(pkt.data)) << 64 + +acklock = ReentrantLock() + +#= + handle_ack_timeout(tim, twin, msg, msgid) + +Persist a PubSub message in case the acknowledge message is not received. +=# +function handle_ack_timeout(tim, twin, msg, msgid) + lock(acklock) do + if haskey(twin.acktimer, msgid) + try + twin.router.park(CONFIG.broker_ctx, twin, msg) + catch e + @error "[$twin] ack_timeout: $e" + end + end + delete!(twin.acktimer, msgid) + end +end + +function notreactive(twin, msg) + twin.reactive === false && isa(msg.content, PubSubMsg) +end + +#= + signal!(twin, msg::Msg) + +Send `msg` to client or enqueue it if it is offline. + +Register the message into Twin.sent table. +=# +function signal!(twin, msg) + @debug "[$twin] message>>: $msg, offline:$(offline(twin)), type:$(msg.ptype)" + if (offline(twin) || notreactive(twin, msg)) && msg.ptype === TYPE_PUB + twin.router.park(CONFIG.broker_ctx, twin, msg.content) + else + # current message + if isa(msg.content, RpcReqMsg) + # add to sent table + twin.sent[msg.content.id] = SentData(time(), msg) + end + + pkt = msg.content + @mlog "[$twin] -> $pkt" + try + transport_send(twin, twin.sock, pkt) + catch e + @debug "[$twin] going offline: $e" + detach(twin) + if msg.ptype === TYPE_PUB + twin.router.park(CONFIG.broker_ctx, twin, msg.content) + end + end + end + + return nothing +end + +### # Return true if Twin is interested to the message. +### function interested(twin, message::Dict)::Bool +### true +### end + +#= + callback_or(fn::Function, router::AbstractRouter, callback::Symbol) + +Invoke `callback` function if it is injected via the plugin module otherwise invoke `fn`. +=# +function callback_or(fn::Function, router::AbstractRouter, callback::Symbol) + if CONFIG.broker_plugin !== nothing && isdefined(CONFIG.broker_plugin, callback) + cb = getfield(CONFIG.broker_plugin, callback) + cb(CONFIG.broker_ctx, router) + else + fn() + end +end + +#= + callback_and(fn, cb::Symbol, router::AbstractRouter, twin::Twin, msg::RembusMsg) + +Get `cb` function and invoke it if is injected via the plugin module and then invoke `fn`. + +If callback throws an error then `fn` is not called. + +# Arguments + +- `fn::Function`: the function to invoke anyway if `cb` does not throw. +- `cb::Symbol`: the name of the method defined in the external plugin +- `router::AbstractRouter`: the instance of the broker router +- `twin::Twin`: the target twin +- `msg::RembusMsg`: the message to handle +=# +function callback_and( + fn::Function, cb::Symbol, router::AbstractRouter, twin::Twin, msg::RembusMsg +) + try + if CONFIG.broker_plugin !== nothing && isdefined(CONFIG.broker_plugin, cb) + cb = getfield(CONFIG.broker_plugin, cb) + cb(CONFIG.broker_ctx, router, twin, msg) + end + fn() + catch e + @error "$cb callback error: $e" + end +end + +""" + set_broker_plugin(extension::Module) + +Inject the module that implements the functions related to twin lifecycle. +""" +function set_broker_plugin(extension::Module) + CONFIG.broker_plugin = extension +end + +""" + set_broker_context(ctx) + +Set the object to be passed ad first argument to functions related to twin lifecycle. + +Actually the functions that use `ctx` are: + +- `twin_initialize` +- `twin_finalize` +- `park` +- `unpark` +""" +function set_broker_context(ctx) + CONFIG.broker_ctx = ctx +end + +function command_line() + s = ArgParseSettings() + @add_arg_table! s begin + "--reset", "-r" + help = "factory reset, clean up broker configuration" + action = :store_true + "--secure", "-s" + help = "accept wss and tls connections on BROKER_WS_PORT and BROKER_TCP_PORT" + action = :store_true + "--debug", "-d" + help = "enable debug logs" + action = :store_true + end + return parse_args(s) +end + +function caronte_reset() + foreach(rm, readdir(Rembus.twindir(), join=true)) + foreach(rm, filter(isfile, readdir(Rembus.CONFIG.db, join=true))) +end + +""" + caronte(; wait=true, exit_when_done=true) + +Start the broker. + +Return immediately when `wait` is false, otherwise blocks until shut down. + +Return instead of exiting if `exit_when_done` is false. +""" +function caronte(; wait=true, exit_when_done=true, args=Dict()) + if isempty(args) + args = command_line() + end + + if haskey(args, "debug") && args["debug"] === true + ENV["REMBUS_DEBUG"] = "1" + end + + if haskey(args, "reset") && args["reset"] === true + Rembus.caronte_reset() + end + + issecure = get(args, "secure", false) + + setup(CONFIG) + router = Router() + + tasks = [ + supervisor("twins", terminateif=:shutdown), + process(broker, args=(router,)), + process(serve_tcp, args=(router, issecure), restart=:transient), + process(serve_ws, args=(router, issecure), restart=:transient, stop_waiting_after=2.0), + process(serve_zeromq, args=(router,), restart=:transient, debounce_time=2) + ] + sv = supervise( + [supervisor("caronte", tasks, strategy=:one_for_all, intensity=0)], + wait=wait + ) + if exit_when_done + exit(0) + end + + return sv +end + +function caronted()::Cint + caronte() + return 0 +end + + +function serve(embedded::Embedded; wait=true, exit_when_done=true, secure=false) + tasks = [ + supervisor("twins", terminateif=:shutdown), + process( + serve_ws, + args=(embedded, secure), + restart=:transient, + stop_waiting_after=2.0 + ), + ] + sv = supervise( + [supervisor("caronte", tasks, strategy=:one_for_one)], + intensity=5, + wait=wait + ) + if exit_when_done + exit(0) + end + + return sv +end + +function router_configuration(router) + cfg = Dict("twins" => Dict()) + for (tid, twin) in router.id_twin + cfg["twins"][tid] = Dict("retroactives" => twin.retroactive) + end + + return cfg +end + +prettystr(msg::RembusMsg) = "RembusMsg: $msg" + +function prettystr(msg::PubSubMsg) + if isa(msg.data, Vector{UInt8}) + len = length(msg.data) + cnt = len > 10 ? msg.data[1:10] : msg.data + return "binary[$len] $cnt ..." + else + return msg.topic + end +end + +function client_receiver(router::Router, sock) + cid = string(uuid4()) + twin = create_twin(cid, router) + @debug "[anonymous] client bound to twin id [$cid]" + + # start the trusted client task + twin.sock = sock + + # ws/tcp socket receiver task + authtwin = anonymous_twin_receiver(router, twin) + + # upgrade to named or authenticated twin if it returns true + if (authtwin !== nothing) + twin_receiver(router, authtwin) + end + + return nothing +end + +function client_receiver(router::Embedded, ws) + cid = string(uuid4()) + twin = create_twin(cid, router) + @debug "[embedded] client bound to twin id [$cid]" + # start the trusted client task + twin.sock = ws + @debug "embedded receiver is connected" + while isopen(ws) + try + payload = transport_read(ws) + if isempty(payload) + @debug "[$twin]: connection close" + break + end + msg::RembusMsg = broker_parse(payload) + @mlog("[$twin] <- $(prettystr(msg))") + + if isa(msg, RpcReqMsg) + rpc_request(router, twin, msg) + elseif isa(msg, PubSubMsg) + pubsub_msg(router, twin, msg) + elseif isa(msg, IdentityMsg) + dare = challenge(router, twin, msg) + response = Msg(TYPE_RESPONSE, dare, twin) + respond(router, response) + elseif isa(msg, Attestation) + attestation(router, twin, msg) + elseif isa(msg, AckMsg) + ack_msg(twin, msg) + end + catch e + @debug "[embedded] error: $e" + if isa(e, EOFError) + break + end + end + end + end_receiver(twin) + + return nothing +end + +function secure_config() + trust_store = keystore_dir() + + entropy = MbedTLS.Entropy() + rng = MbedTLS.CtrDrbg() + MbedTLS.seed!(rng, entropy) + + sslconfig = MbedTLS.SSLConfig( + joinpath(trust_store, "caronte.crt"), + joinpath(trust_store, "caronte.key") + ) + MbedTLS.rng!(sslconfig, rng) + + function show_debug(level, filename, number, msg) + @show level, filename, number, msg + end + + MbedTLS.dbg!(sslconfig, show_debug) + return sslconfig +end + +function listener(proc, router, sslconfig) + IP = "0.0.0.0" + caronte_port = parse(UInt16, get(ENV, "BROKER_WS_PORT", "8000")) + + server = Sockets.listen(Sockets.InetAddr(parse(IPAddr, IP), caronte_port)) + router.ws_server = server + proto = (sslconfig === nothing) ? "ws" : "wss" + @info "rembus up and running at port $proto:$caronte_port" + + setphase(proc, :listen) + + HTTP.WebSockets.listen(IP, caronte_port, server=server, sslconfig=sslconfig) do ws + client_receiver(router, ws) + end +end + +function serve_ws(td, router, issecure=false) + sslconfig = nothing + + if issecure + sslconfig = secure_config() + end + + try + listener(td, router, sslconfig) + catch e + if !isa(e, Visor.ProcessInterrupt) + @error "ws server: $e" + @showerror e + end + rethrow() + finally + @debug "serve_ws closed" + setphase(td, :terminate) + isdefined(router, :ws_server) && close(router.ws_server) + end +end + +function serve_zeromq(pd, router) + @debug "starting serve_zeromq [$(pd.id)]" + port = parse(UInt16, get(ENV, "BROKER_ZMQ_PORT", "8002")) + context = ZMQ.Context() + router.zmqsocket = Socket(context, ROUTER) + ZMQ.bind(router.zmqsocket, "tcp://*:$port") + + try + @info "rembus up and running at zmq ports $port" + setphase(pd, :listen) + zeromq_receiver(router) + catch e + # consider ProcessInterrupt a normal termination because + # zeromq_receiver is not polling for supervisor shutdown message + if !isa(e, Visor.ProcessInterrupt) + @error "[serve_zeromq] error: $e" + rethrow() + end + finally + setphase(pd, :terminate) + ZMQ.close(router.zmqsocket) + ZMQ.close(context) + end +end + +function serve_tcp(pd, router, issecure=false) + proto = "tcp" + server = nothing + try + IP = "0.0.0.0" + caronte_port = parse(UInt16, get(ENV, "BROKER_TCP_PORT", "8001")) + setphase(pd, :listen) + + if issecure + proto = "tls" + sslconfig = secure_config() + + server = Sockets.listen(Sockets.InetAddr(parse(IPAddr, IP), caronte_port)) + router.server = server + @info "rembus up and running at port $proto:$caronte_port" + while true + try + sock = accept(server) + ctx = MbedTLS.SSLContext() + MbedTLS.setup!(ctx, sslconfig) + MbedTLS.associate!(ctx, sock) + MbedTLS.handshake(ctx) + @async client_receiver(router, ctx) + catch e + if !isa(e, Visor.ProcessInterrupt) + @error "tcp server: $e" + @showerror e + end + rethrow() + end + end + else + server = Sockets.listen(Sockets.InetAddr(parse(IPAddr, IP), caronte_port)) + router.server = server + @info "rembus up and running at port $proto:$caronte_port" + while true + try + sock = accept(server) + @async client_receiver(router, sock) + catch e + if !isa(e, Visor.ProcessInterrupt) + @error "tcp server: $e" + @showerror e + end + rethrow() + end + end + end + finally + setphase(pd, :terminate) + server !== nothing && close(server) + end +end + +function islistening(wait=5) + procs = ["caronte.serve_ws", "caronte.serve_tcp", "caronte.serve_zeromq"] + tcount = 0 + while tcount < wait + if all(p -> getphase(p) === :listen, from.(procs)) + return true + end + tcount += 0.2 + end + + return false +end + +isconnected(twin) = twin.sock !== nothing && isopen(twin.sock) + +function first_up(router, topic, implementors) + @debug "[$topic] first_up balancer" + for target in implementors + @debug "[$topic] candidate target: $target" + if isconnected(target) + return target + end + end + + return nothing +end + +function round_robin(router, topic, implementors) + target = nothing + if !isempty(implementors) + len = length(implementors) + @debug "[$topic]: $len implementors" + current_index = get(router.last_invoked, topic, 0) + if current_index === 0 + for impl in implementors + current_index += 1 + !isconnected(impl) && continue + target = impl + router.last_invoked[topic] = current_index + break + end + else + cursor = 1 + current_index = current_index >= len ? 1 : current_index + 1 + for impl in implementors + if current_index > cursor + if target === nothing && isconnected(impl) + target = impl + router.last_invoked[topic] = cursor + end + cursor += 1 + else + if isconnected(impl) + target = impl + router.last_invoked[topic] = cursor + break + else + cursor += 1 + end + end + end + end + end + + return target +end + +Base.isless(t1::Twin, t2::Twin) = length(t1.sent) < length(t2.sent) + +function less_busy(router, topic, implementors) + up_and_running = [impl for impl in implementors if isconnected(impl)] + if isempty(up_and_running) + return nothing + else + return min(up_and_running...) + end +end + +#= + select_twin(router, topic, implementors) + +Return an online implementor ready to execute the method associated to the topic. +=# +function select_twin(router, topic, implementors) + target = nothing + @info "[$topic] balancer: $(CONFIG.balancer)" + if CONFIG.balancer === "first_up" + target = first_up(router, topic, implementors) + elseif CONFIG.balancer === "round_robin" + target = round_robin(router, topic, implementors) + elseif CONFIG.balancer === "less_busy" + target = less_busy(router, topic, implementors) + end + + return target +end + +#= + broadcast!(router, msg) + +Broadcast the `topic` data `msg` to all interested clients. +=# +function broadcast!(router, msg) + authtwins = Set{Twin}() + if msg.ptype == TYPE_PUB + # the interest * (subscribe to all topics) is enabled + # only for pubsub messages and not for rpc methods. + topic = msg.content.topic + bmsg = msg.content + + twins = get(router.topic_interests, "*", Set{Twin}()) + # broadcast to twins that are admins and to twins that are authorized to + # subscribe to topic + for twin in twins + if twin.id in router.admins + push!(authtwins, twin) + elseif haskey(router.topic_auth, topic) + if haskey(router.topic_auth[topic], twin.id) + # it is a private topic, check if twin is authorized + push!(authtwins, twin) + end + else + # it is a public topic, all twins may be broadcasted + push!(authtwins, twin) + end + end + elseif isdefined(msg, :reqdata) + topic = msg.reqdata.topic + bmsg = PubSubMsg(topic, msg.reqdata.data) + else + @debug "no broadcast for [$msg]: request data not available or embedded method" + return nothing + end + + union!(authtwins, get(router.topic_interests, topic, Set{Twin}())) + newmsg = Msg(TYPE_PUB, bmsg, msg.twchannel) + + for tw in filter(t -> t.process.inbox != msg.twchannel.process.inbox, authtwins) + @debug "broadcasting $topic to $(tw.id): [$newmsg]" + put!(tw.process.inbox, newmsg) + end + + return nothing +end + +#= + isauth(router::Router, twin::Twin, topic::AbstractString) + +Return true if topic is public or client is authorized to bind to topic. +=# +function isauth(router::Router, twin::Twin, topic::AbstractString) + # check if topic is private + if haskey(router.topic_auth, topic) + # check if twin is authorized to bind to topic + if !haskey(router.topic_auth[topic], twin.id) + return false + end + end + + # topic is public or twin is authorized + return true +end + +#= + isadmin(router, twin, cmd) +Check if twin client has admin privilege. +=# +function isadmin(router, twin, cmd) + sts = twin.id in router.admins + if !sts + @error "$cmd failed: $(twin.id) not authorized" + end + + return sts +end + +function respond(router::Router, msg::Msg) + put!(msg.twchannel.process.inbox, msg) + + if msg.content.status != STS_SUCCESS + return + end + + # broadcast! to all interested twins + broadcast!(router, msg) + + return nothing +end + +respond(router::Embedded, msg::Msg) = put!(msg.twchannel.process.inbox, msg) + +function uptime(router) + utime = time() - router.start_ts + return "up for $(Int(floor(utime))) seconds" +end + +function getargs(data) + if isa(data, ZMQ.Message) + args = decode(Vector{UInt8}(data)) + else + args = data + end + if args isa Vector + return args + elseif args === nothing + return [] + else + return [args] + end +end + +function embedded_eval(router, twin::Twin, msg::RembusMsg) + result = nothing + sts = STS_GENERIC_ERROR + if haskey(router.topic_function, msg.topic) + try + result = router.topic_function[msg.topic](twin, getargs(msg.data)...) + sts = STS_SUCCESS + catch e + @debug "[$(msg.topic)] embedded error (method too young?): $e" + result = "$e" + sts = STS_METHOD_EXCEPTION + + if isa(e, MethodError) + try + result = Base.invokelatest( + router.topic_function[msg.topic], + twin, + getargs(msg.data)... + ) + sts = STS_SUCCESS + catch e + result = "$e" + end + end + end + + return (true, isa(msg, RpcReqMsg) ? ResMsg(msg, sts, result) : nothing) + else + return (false, nothing) + end +end + +function caronte_embedded_method(router, twin::Twin, msg::RembusMsg) + (found, resmsg) = embedded_eval(router, twin, msg) + + if found + if isa(resmsg, ResMsg) + response = Msg(TYPE_RESPONSE, resmsg, twin) + respond(router, response) + end + end + + return found +end + +#= + broker(self, router) + +Rembus broker main task. +=# +function broker(self, router) + @debug "[broker] starting" + try + router.process = self + init(router) + + # example for registering a broker implementor + router.topic_function["uptime"] = (session) -> uptime(router) + router.topic_function["version"] = (session) -> Rembus.VERSION + + for msg in self.inbox + # process control messages + !isshutdown(msg) || break + + @debug "[broker] recv $(typeof(msg)): $msg" + if isa(msg, Msg) + if msg.ptype == TYPE_PUB + if !caronte_embedded_method(router, msg.twchannel, msg.content) + # publish to interested twins + broadcast!(router, msg) + end + elseif msg.ptype == TYPE_RPC + topic = msg.content.topic + if caronte_embedded_method(router, msg.twchannel, msg.content) + else + # find an implementor + if haskey(router.topic_impls, topic) + # request a method exec + @debug "[broker] finding an target implementor for $topic" + implementors = router.topic_impls[topic] + target = select_twin(router, topic, implementors) + @debug "[broker] target implementor: [$target]" + if target === nothing + msg.content = ResMsg(msg.content, STS_METHOD_UNAVAILABLE) + put!(msg.twchannel.process.inbox, msg) + elseif target.process.inbox === msg.twchannel.process.inbox + @warn "[$(target.id)]: loopback detected" + msg.content = ResMsg(msg.content, STS_METHOD_LOOPBACK) + put!(msg.twchannel.process.inbox, msg) + elseif target !== nothing + @debug "implementor target: $(target.id)" + put!(target.process.inbox, msg) + end + + else + # method implementor not found + msg.content = ResMsg(msg.content, STS_METHOD_NOT_FOUND) + put!(msg.twchannel.process.inbox, msg) + end + end + elseif msg.ptype == TYPE_RESPONSE + # it is a result from an implementor + # reply toward the client that has made the request + respond(router, msg) + end + # @debug "waiting next msg ..." + else + @warn "unknow $(typeof(msg)) message $msg " + end + end + catch e + @error "[broker] error: $e" + @showerror e + rethrow() + finally + save_configuration(router) + end + @debug "[broker] done" +end + +function watch_extfile(router, extfile, time_window=4) + changed = false + while true + try + event = watch_folder(extfile, time_window) + if event.second.timedout + if changed + @debug "rembus $extfile change detected: reloading" + app_topics(router) + changed = false + else + # @info "watching ..." + end + else + changed = true + end + catch e + @error "watch extfile: $e" + end + end +end + +function caronte_context(fn, ctx) + if ctx === nothing + return data -> fn(data...) + else + return data -> fn(ctx, data...) + end +end + +function eval_optional(router, modname, fname) + sts = eval(Meta.parse("isdefined(Main.$modname, :$fname)")) + if sts + return Base.invokelatest(Base.eval(Main, Meta.parse("$modname.$fname")), router) + else + return nothing + end +end + +#= + boot(router) + +Setup the router. +=# +function boot(router) + appdir = joinpath(CONFIG.db, "apps") + if !isdir(appdir) + mkpath(appdir) + end + + load_configuration(router) + return nothing +end + +function init(router) + if isinteractive() + Logging.disable_logging(Logging.Info) + else + logging(debug=[]) + end + + boot(router) + @debug "broker datadir: $(CONFIG.db)" + + if CONFIG.broker_plugin !== nothing + if isdefined(CONFIG.broker_plugin, :park) && + isdefined(CONFIG.broker_plugin, :unpark) + router.park = getfield(CONFIG.broker_plugin, :park) + router.unpark = getfield(CONFIG.broker_plugin, :unpark) + end + + if isdefined(CONFIG.broker_plugin, :twin_initialize) + router.twin_initialize = getfield(CONFIG.broker_plugin, :twin_initialize) + end + if isdefined(CONFIG.broker_plugin, :twin_finalize) + router.twin_finalize = getfield(CONFIG.broker_plugin, :twin_finalize) + end + + topics = names(CONFIG.broker_plugin) + exposed = filter(sym -> sym !== Symbol(CONFIG.broker_plugin), topics) + @debug "plugin exposed methods: $exposed" + for topic in exposed + router.topic_function[String(topic)] = getfield(CONFIG.broker_plugin, topic) + end + end + + return nothing +end diff --git a/src/cbor.jl b/src/cbor.jl new file mode 100644 index 0000000..71aeb82 --- /dev/null +++ b/src/cbor.jl @@ -0,0 +1,60 @@ +#= +Copyright (c) 2016 Saurav Sachidanand + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +num2hex(n) = string(n, base=16, pad=sizeof(n) * 2) +num2hex(n::AbstractFloat) = num2hex(reinterpret(Unsigned, n)) +hex2num(s) = reinterpret(Float64, parse(UInt64, s, base=16)) +hex(n) = string(n, base=16) + +struct Tag{T} + id::Int + data::T +end +Base.:(==)(a::Tag, b::Tag) = a.id == b.id && a.data == b.data +Tag(id::Integer, data) = Tag(Int(id), data) + +### include("constants.jl") +### include("encode.jl") +### include("decode.jl") + +export encode +export decode, decode_with_iana +export Undefined + +function decode(data::Array{UInt8,1}) + return decode_internal(IOBuffer(data)) +end + +function decode(data::IO) + return decode(read(data)) +end + +function encode(data) + io = IOBuffer() + encode(io, data) + return take!(io) +end diff --git a/src/configuration.jl b/src/configuration.jl new file mode 100755 index 0000000..f03e709 --- /dev/null +++ b/src/configuration.jl @@ -0,0 +1,108 @@ +#= +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +const DEFAULT_APP_NAME = "rembus" + +macro showerror(e) + quote + if CONFIG.stacktrace + showerror(stdout, $(esc(e)), catch_backtrace()) + end + end +end + +function component_id(cfg) + url = get(cfg, "cid", get(ENV, "REMBUS_CID", DEFAULT_APP_NAME)) + uri = URI(url) + proto = uri.scheme + if proto == "" + name = uri.path + else + name = uri.host + end + name +end + +mutable struct Settings + zmq_ping_interval::Float32 + ws_ping_interval::Float32 + balancer::String + db::String + log::String + debug_modules::Vector{Module} + overwrite_connection::Bool + stacktrace # log stacktrace on error + metering # log in and out messages + rawdump # log in and out raw bytes + cid::String # rembus default component cid + connection_retry_period::Float32 # seconds between reconnection attempts + broker_plugin::Union{Nothing,Module} + broker_ctx::Any + Settings() = begin + zmq_ping_interval = 0 + ws_ping_interval = 0 + balancer = "first_up" + db = joinpath(get(ENV, "HOME", "."), "caronte") + log = "stdout" + overwrite_connection = true + stacktrace = false + metering = false + rawdump = false + cid = DEFAULT_APP_NAME + connection_retry_period = 2.0 + debug_modules = [] + + new(zmq_ping_interval, ws_ping_interval, balancer, db, log, debug_modules, + overwrite_connection, stacktrace, metering, rawdump, cid, + connection_retry_period, nothing, nothing) + end +end + +set_balancer(policy::AbstractString) = set_balancer(CONFIG, policy) + +function set_balancer(setting, policy) + #balancer = get(cfg, "balancer", get(ENV, "BROKER_BALANCER", "first_up")) + if !(policy in ["first_up", "less_busy", "round_robin"]) + error("wrong balancer, must be one of first_up, less_busy, round_robin") + end + setting.balancer = policy + + return nothing +end + +function setup(setting) + home = get(ENV, "HOME", ".") + cfg = get(Base.get_preferences(), "Rembus", Dict()) + + setting.zmq_ping_interval = get(cfg, "zmq_ping_interval", + parse(Float32, get(ENV, "REMBUS_ZMQ_PING_INTERVAL", "10"))) + + setting.ws_ping_interval = get(cfg, "ws_ping_interval", + parse(Float32, get(ENV, "REMBUS_WS_PING_INTERVAL", "120"))) + + setting.db = get(cfg, "db", get(ENV, "REMBUS_DB", joinpath(home, "caronte"))) + setting.log = get(cfg, "log", get(ENV, "BROKER_LOG", "stdout")) + setting.overwrite_connection = get(cfg, "overwrite_connection", true) + setting.stacktrace = get(cfg, "stacktrace", false) + setting.metering = get(cfg, "metering", false) + setting.rawdump = get(cfg, "rawdump", false) + setting.cid = component_id(cfg) + setting.connection_retry_period = get(cfg, "connection_retry_period", 2.0) + + if get(ENV, "REMBUS_DEBUG", "0") == "1" + setting.debug_modules = [Rembus, Visor] + else + setting.debug_modules = [] + end + + balancer = get(cfg, "balancer", get(ENV, "BROKER_BALANCER", "first_up")) + set_balancer(setting, balancer) + + return nothing +end + +context() = CONFIG.broker_ctx diff --git a/src/constants.jl b/src/constants.jl new file mode 100644 index 0000000..c083e15 --- /dev/null +++ b/src/constants.jl @@ -0,0 +1,144 @@ +#= +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +const VERSION = "0.1.0" + +const Rembus.CONFIG = Rembus.Settings() + +const DATAFRAME_TAG = 80 + +const APP_CONFIG_DIR = ".config" + +const PING_INTERVAL = 10 +const PONG_STRING = "*_pong_*" + +const BROKER_CONFIG = "__config__" +const CID = "cid" +const COMMAND = "cmd" +const DATA = :data +const RETROACTIVE = "retroactive" +const STATUS = "status" + +const REACTIVE_CMD = "reactive" +const ENABLE_ACK_CMD = "enable_ack" +const DISABLE_ACK_CMD = "disable_ack" +const RESET_ROUTER_CMD = "reset_router" +const SHUTDOWN_CMD = "shutdown" +const ENABLE_DEBUG_CMD = "enable_debug" +const DISABLE_DEBUG_CMD = "disable_debug" +const UPTIME_CMD = "uptime" +const ADD_INTEREST_CMD = "add_interest" +const REMOVE_INTEREST_CMD = "remove_interest" +const ADD_IMPL_CMD = "add_impl" +const REMOVE_IMPL_CMD = "remove_impl" +const AUTHORIZE_CMD = "authorize" +const UNAUTHORIZE_CMD = "unauthorize" +const PRIVATE_TOPIC_CMD = "private_topic" +const PUBLIC_TOPIC_CMD = "public_topic" +const TOPICS_CONFIG_CMD = "topics_config" +const BROKER_CONFIG_CMD = "broker_config" +const LOAD_CONFIG_CMD = "load_config" +const SAVE_CONFIG_CMD = "save_config" + +const TYPE_IDENTITY::UInt8 = 0 +const TYPE_PUB::UInt8 = 1 +const TYPE_RPC::UInt8 = 2 +const TYPE_ADMIN::UInt8 = 3 +const TYPE_RESPONSE::UInt8 = 4 +const TYPE_ACK::UInt8 = 5 +const TYPE_UNREGISTER::UInt8 = 9 +const TYPE_REGISTER::UInt8 = 10 +const TYPE_ATTESTATION::UInt8 = 11 + +# ZeroMQ periodic ping +const TYPE_PING::UInt8 = 12 +const TYPE_PONG::UInt8 = 13 + +const TYPE_REMOVE::UInt8 = 14 +const TYPE_CLOSE::UInt8 = 15 + +const REACTIVE_DISABLE::Bool = false +const REACTIVE_ENABLE::Bool = true + +const ACK_WAIT_TIME = 6 + +const STS_SUCCESS::UInt8 = 0 +const STS_GENERIC_ERROR::UInt8 = 10 +const STS_CHALLENGE::UInt8 = 11 +const STS_IDENTIFICATION_ERROR::UInt8 = 20 +const STS_METHOD_EXCEPTION::UInt8 = 40 +const STS_METHOD_NOT_FOUND::UInt8 = 42 +const STS_METHOD_UNAVAILABLE::UInt8 = 43 +const STS_METHOD_LOOPBACK::UInt8 = 44 +const STS_TARGET_NOT_FOUND::UInt8 = 45 +const STS_TARGET_DOWN::UInt8 = 46 +const STS_UNKNOWN_ADMIN_CMD::UInt8 = 47 +const STS_NAME_ALREADY_TAKEN::UInt8 = 60 +const STS_SHUTDOWN::UInt8 = 100 + +# Rembus timeout +const STS_TIMEOUT::UInt8 = 70 + +const TYPE_0 = zero(UInt8) +const TYPE_1 = one(UInt8) << 5 +const TYPE_2 = UInt8(2) << 5 +const TYPE_3 = UInt8(3) << 5 +const TYPE_4 = UInt8(4) << 5 +const TYPE_5 = UInt8(5) << 5 +const TYPE_6 = UInt8(6) << 5 +const TYPE_7 = UInt8(7) << 5 + +const BITS_PER_BYTE = UInt8(8) +const HEX_BASE = Int(16) +const LOWEST_ORDER_BYTE_MASK = 0xFF + +const TYPE_BITS_MASK = UInt8(0b1110_0000) +const ADDNTL_INFO_MASK = UInt8(0b0001_1111) + +const ADDNTL_INFO_UINT8 = UInt8(24) +const ADDNTL_INFO_UINT16 = UInt8(25) +const ADDNTL_INFO_UINT32 = UInt8(26) +const ADDNTL_INFO_UINT64 = UInt8(27) + +const SINGLE_BYTE_SIMPLE_PLUS_ONE = UInt8(24) +const SIMPLE_FALSE = UInt8(20) +const SIMPLE_TRUE = UInt8(21) +const SIMPLE_NULL = UInt8(22) +const SIMPLE_UNDEF = UInt8(23) + +const ADDNTL_INFO_FLOAT16 = UInt8(25) +const ADDNTL_INFO_FLOAT32 = UInt8(26) +const ADDNTL_INFO_FLOAT64 = UInt8(27) + +const ADDNTL_INFO_INDEF = UInt8(31) +const BREAK_INDEF = TYPE_7 | UInt8(31) + +const SINGLE_BYTE_UINT_PLUS_ONE = 24 +const UINT8_MAX_PLUS_ONE = 0x100 +const UINT16_MAX_PLUS_ONE = 0x10000 +const UINT32_MAX_PLUS_ONE = 0x100000000 +const UINT64_MAX_PLUS_ONE = 0x10000000000000000 + +const INT8_MAX_POSITIVE = 0x7f +const INT16_MAX_POSITIVE = 0x7fff +const INT32_MAX_POSITIVE = 0x7fffffff +const INT64_MAX_POSITIVE = 0x7fffffffffffffff + +const SIZE_OF_FLOAT64 = sizeof(Float64) +const SIZE_OF_FLOAT32 = sizeof(Float32) +const SIZE_OF_FLOAT16 = sizeof(Float16) + +const POS_BIG_INT_TAG = UInt8(2) +const NEG_BIG_INT_TAG = UInt8(3) + +const CBOR_FALSE_BYTE = UInt8(TYPE_7 | 20) +const CBOR_TRUE_BYTE = UInt8(TYPE_7 | 21) +const CBOR_NULL_BYTE = UInt8(TYPE_7 | 22) +const CBOR_UNDEF_BYTE = UInt8(TYPE_7 | 23) + + +const CUSTOM_LANGUAGE_TYPE = 27 diff --git a/src/decode.jl b/src/decode.jl new file mode 100644 index 0000000..0874559 --- /dev/null +++ b/src/decode.jl @@ -0,0 +1,228 @@ +#= +Copyright (c) 2016 Saurav Sachidanand + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +function type_from_fields(::Type{T}, fields) where {T} + ccall(:jl_new_structv, Any, (Any, Ptr{Cvoid}, UInt32), T, fields, length(fields)) +end + +function peekbyte(io::IO) + mark(io) + byte = read(io, UInt8) + reset(io) + return byte +end + +struct UndefIter{IO,F} + f::F + io::IO +end +Base.IteratorSize(::Type{<:UndefIter}) = Base.SizeUnknown() + +function Base.iterate(x::UndefIter, state=nothing) + peekbyte(x.io) == BREAK_INDEF && return nothing + return x.f(x.io), nothing +end + +function decode_ntimes(f, io::IO) + first_byte = peekbyte(io) + if (first_byte & ADDNTL_INFO_MASK) == ADDNTL_INFO_INDEF + skip(io, 1) # skip first byte + return UndefIter(f, io) + else + return (f(io) for i in 1:decode_unsigned(io)) + end +end + +function decode_unsigned(io::IO) + addntl_info = read(io, UInt8) & ADDNTL_INFO_MASK + if addntl_info < SINGLE_BYTE_UINT_PLUS_ONE + return addntl_info + elseif addntl_info == ADDNTL_INFO_UINT8 + return bswap(read(io, UInt8)) + elseif addntl_info == ADDNTL_INFO_UINT16 + return bswap(read(io, UInt16)) + elseif addntl_info == ADDNTL_INFO_UINT32 + return bswap(read(io, UInt32)) + elseif addntl_info == ADDNTL_INFO_UINT64 + return bswap(read(io, UInt64)) + else + error("Unknown Int type") + end +end + + + +decode_internal(io::IO, ::Val{TYPE_0}) = decode_unsigned(io) + +function decode_internal(io::IO, ::Val{TYPE_1}) + addntl_info = read(io, UInt8) & ADDNTL_INFO_MASK + if addntl_info < SINGLE_BYTE_UINT_PLUS_ONE + return -signed(addntl_info + Int8(1)) + elseif addntl_info == ADDNTL_INFO_UINT8 + data = bswap(read(io, UInt8)) + if data > INT8_MAX_POSITIVE + return -Int16(data + one(data)) + else + return -signed(data + one(data)) + end + elseif addntl_info == ADDNTL_INFO_UINT16 + data = bswap(read(io, UInt16)) + if data > INT16_MAX_POSITIVE + return -Int32(data + one(data)) + else + return -signed(data + one(data)) + end + elseif addntl_info == ADDNTL_INFO_UINT32 + data = bswap(read(io, UInt32)) + if data > INT32_MAX_POSITIVE + return -Int64(data + one(data)) + else + return -signed(data + one(data)) + end + elseif addntl_info == ADDNTL_INFO_UINT64 + data = bswap(read(io, UInt64)) + if data > INT64_MAX_POSITIVE + return -Int128(data + one(data)) + else + return -signed(data + one(data)) + end + else + error("Unknown Int type") + end +end + +# Decode Byte Array +function decode_internal(io::IO, ::Val{TYPE_2}) + if (peekbyte(io) & ADDNTL_INFO_MASK) == ADDNTL_INFO_INDEF + skip(io, 1) + result = IOBuffer() + while peekbyte(io) !== BREAK_INDEF + write(result, decode_internal(io)) + end + return take!(result) + else + return read(io, decode_unsigned(io)) + end +end + +# Decode String +function decode_internal(io::IO, ::Val{TYPE_3}) + if (peekbyte(io) & ADDNTL_INFO_MASK) == ADDNTL_INFO_INDEF + skip(io, 1) + result = IOBuffer() + while peekbyte(io) !== BREAK_INDEF + write(result, decode_internal(io)) + end + return String(take!(result)) + else + return String(read(io, decode_unsigned(io))) + end +end + +# Decode Vector of arbitrary elements +function decode_internal(io::IO, ::Val{TYPE_4}) + return map(identity, decode_ntimes(decode_internal, io)) +end + +# Decode Dict +function decode_internal(io::IO, ::Val{TYPE_5}) + return Dict(decode_ntimes(io) do io + decode_internal(io) => decode_internal(io) + end) +end + +# Decode Tagged type +function decode_internal(io::IO, ::Val{TYPE_6}) + tag = decode_unsigned(io) + data = decode_internal(io) + if tag in (POS_BIG_INT_TAG, NEG_BIG_INT_TAG) + big_int = parse( + BigInt, bytes2hex(data), base=HEX_BASE + ) + if tag == NEG_BIG_INT_TAG + big_int = -(big_int + 1) + end + return big_int + end + + if tag == CUSTOM_LANGUAGE_TYPE # Type Tag + name = data[1] + object_serialized = data[2] + if startswith(name, "Julia/") # Julia Type + return deserialize(IOBuffer(object_serialized)) + end + end + # TODO implement other common tags! + return Tag(tag, data) +end + +function decode_internal(io::IO, ::Val{TYPE_7}) + first_byte = read(io, UInt8) + addntl_info = first_byte & ADDNTL_INFO_MASK + if addntl_info < SINGLE_BYTE_SIMPLE_PLUS_ONE + 1 + simple_val = if addntl_info < SINGLE_BYTE_SIMPLE_PLUS_ONE + addntl_info + else + read(io, UInt8) + end + if simple_val == SIMPLE_FALSE + return false + elseif simple_val == SIMPLE_TRUE + return true + elseif simple_val == SIMPLE_NULL + return nothing + elseif simple_val == SIMPLE_UNDEF + return Undefined() + else + return simple_val + end + else + if addntl_info == ADDNTL_INFO_FLOAT64 + return reinterpret(Float64, ntoh(read(io, UInt64))) + elseif addntl_info == ADDNTL_INFO_FLOAT32 + return reinterpret(Float32, ntoh(read(io, UInt32))) + elseif addntl_info == ADDNTL_INFO_FLOAT16 + return reinterpret(Float16, ntoh(read(io, UInt16))) + else + error("Unsupported Float Type!") + end + end +end + +function decode_internal(io::IO) + # leave startbyte in io + first_byte = peekbyte(io) + typ = first_byte & TYPE_BITS_MASK + typ == TYPE_0 && return decode_internal(io, Val(TYPE_0)) + typ == TYPE_1 && return decode_internal(io, Val(TYPE_1)) + typ == TYPE_2 && return decode_internal(io, Val(TYPE_2)) + typ == TYPE_3 && return decode_internal(io, Val(TYPE_3)) + typ == TYPE_4 && return decode_internal(io, Val(TYPE_4)) + typ == TYPE_5 && return decode_internal(io, Val(TYPE_5)) + typ == TYPE_6 && return decode_internal(io, Val(TYPE_6)) + typ == TYPE_7 && return decode_internal(io, Val(TYPE_7)) +end diff --git a/src/encode.jl b/src/encode.jl new file mode 100644 index 0000000..28945ed --- /dev/null +++ b/src/encode.jl @@ -0,0 +1,248 @@ +#= +Copyright (c) 2016 Saurav Sachidanand + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +cbor_tag(::UInt8) = ADDNTL_INFO_UINT8 +cbor_tag(::UInt16) = ADDNTL_INFO_UINT16 +cbor_tag(::UInt32) = ADDNTL_INFO_UINT32 +cbor_tag(::UInt64) = ADDNTL_INFO_UINT64 + +cbor_tag(::Float64) = ADDNTL_INFO_FLOAT64 +cbor_tag(::Float32) = ADDNTL_INFO_FLOAT32 +cbor_tag(::Float16) = ADDNTL_INFO_FLOAT16 + +function encode_unsigned_with_type( + io::IO, typ::UInt8, num::Unsigned +) + if isa(num, UInt8) && num < SINGLE_BYTE_UINT_PLUS_ONE + write(io, typ | UInt8(num)) # smaller 24 gets directly stored in type tag + else + write(io, typ | cbor_tag(num)) + write(io, bswap(num)) + end +end + +function encode_length(io::IO, typ::UInt8, x) + encode_smallest_int(io, typ, x isa String ? sizeof(x) : length(x)) +end + +# Array lengths and other integers (e.g. tags) in CBOR are encoded with smallest integer +# type, which we do with this method! +function encode_smallest_int(io::IO, typ::UInt8, num::Integer) + @assert num >= 0 "array lengths must be greater 0. Found: $num" + if num < SINGLE_BYTE_UINT_PLUS_ONE + write(io, typ | UInt8(num)) # smaller 24 gets directly stored in type tag + elseif num < UINT8_MAX_PLUS_ONE + encode_unsigned_with_type(io, typ, UInt8(num)) + elseif num < UINT16_MAX_PLUS_ONE + encode_unsigned_with_type(io, typ, UInt16(num)) + elseif num < UINT32_MAX_PLUS_ONE + encode_unsigned_with_type(io, typ, UInt32(num)) + elseif num < UINT64_MAX_PLUS_ONE + encode_unsigned_with_type(io, typ, UInt64(num)) + else + error("128-bits ints can't be encoded in the CBOR format.") + end +end + + +function encode(io::IO, float::Union{Float64,Float32,Float16}) + write(io, TYPE_7 | cbor_tag(float)) + # hton only works for 32 + 64, while bswap works for all + write(io, Base.bswap_int(float)) +end + + +# ------- straightforward encoding for a few Julia types +function encode(io::IO, bool::Bool) + write(io, CBOR_FALSE_BYTE + bool) +end + +function encode(io::IO, num::Unsigned) + encode_unsigned_with_type(io, TYPE_0, num) +end + +function encode(io::IO, num::T) where {T<:Signed} + if num >= 0 + ##encode_smallest_int(io, TYPE_0, unsigned(num)) + encode_unsigned_with_type(io, TYPE_0, unsigned(num)) + else + ##encode_smallest_int(io, TYPE_1, unsigned(-num - one(T))) + encode_unsigned_with_type(io, TYPE_1, unsigned(-num - one(T))) + end +end + +function encode(io::IO, byte_string::Vector{UInt8}) + encode_length(io, TYPE_2, byte_string) + write(io, byte_string) +end + +function encode(io::IO, string::String) + encode_length(io, TYPE_3, string) + write(io, string) +end + +function encode(io::IO, list::Vector) + encode_length(io, TYPE_4, list) + for e in list + encode(io, e) + end +end + +function encode(io::IO, map::Dict) + encode_length(io, TYPE_5, map) + for (key, value) in map + encode(io, key) + encode(io, value) + end +end + +function encode(io::IO, big_int::BigInt) + tag = if big_int < 0 + big_int = -big_int - 1 + NEG_BIG_INT_TAG + else + POS_BIG_INT_TAG + end + hex_str = hex(big_int) + if isodd(length(hex_str)) + hex_str = "0" * hex_str + end + encode(io, Tag(tag, hex2bytes(hex_str))) +end + +function encode(io::IO, tag::Tag) + tag.id >= 0 || error("Tag needs to be a positive integer") + encode_with_tag(io, Unsigned(tag.id), tag.data) +end + + +# Wrapper for collections with undefined length, that will then get encoded +# in the cbor format. Underlying is just +struct UndefLength{ET,A} + iter::A +end + +function UndefLength(iter::T) where {T} + UndefLength{eltype(iter),T}(iter) +end + +function UndefLength{T}(iter::A) where {T,A} + UndefLength{T,A}(iter) +end + +Base.iterate(x::UndefLength) = iterate(x.iter) +Base.iterate(x::UndefLength, state) = iterate(x.iter, state) + +# ------- encoding for indefinite length collections +function encode( + io::IO, iter::UndefLength{ET} +) where {ET} + if ET in (Vector{UInt8}, String) + typ = ET == Vector{UInt8} ? TYPE_2 : TYPE_3 + write(io, typ | ADDNTL_INFO_INDEF) + foreach(x -> encode(io, x), iter) + else + typ = ET <: Pair ? TYPE_5 : TYPE_4 # Dict or any array + write(io, typ | ADDNTL_INFO_INDEF) + for e in iter + if e isa Pair + encode(io, e[1]) + encode(io, e[2]) + else + encode(io, e) + end + end + end + write(io, BREAK_INDEF) +end + +# ------- encoding with tags + +function encode_with_tag(io::IO, tag::Unsigned, data) + encode_smallest_int(io, TYPE_6, tag) + encode(io, data) +end + + +struct Undefined +end + +function encode(io::IO, null::Nothing) + write(io, CBOR_NULL_BYTE) +end + +function encode(io::IO, undef::Undefined) + write(io, CBOR_UNDEF_BYTE) +end + +struct SmallInteger{T} + num::T +end +Base.convert(::Type{<:SmallInteger}, x) = SmallInteger(x) +Base.convert(::Type{<:SmallInteger}, x::SmallInteger) = x +Base.:(==)(a::SmallInteger, b::SmallInteger) = a.num == b.num +Base.:(==)(a::Number, b::SmallInteger) = a == b.num +Base.:(==)(a::SmallInteger, b::Number) = a.num == b + +function encode(io::IO, small::SmallInteger{<:Unsigned}) + encode_smallest_int(io, TYPE_0, small.num) +end +function encode(io::IO, small::SmallInteger{<:Signed}) + if small.num >= 0 + encode_smallest_int(io, TYPE_0, unsigned(small.num)) + else + encode_smallest_int(io, TYPE_1, unsigned(-small.num - one(small.num))) + end +end + + +function fields2array(typ::T) where {T} + fnames = fieldnames(T) + getfield.((typ,), [fnames...]) +end + +# Any Julia type get's serialized as Tag 27 +# Tag 27 +# Data Item array [typename, constructargs...] +# Semantics Serialised language-independent object with type name and constructor arguments +# Reference http://cbor.schmorp.de/generic-object +# Contact Marc A. Lehmann +function encode(io::IO, struct_type::T) where {T} + # TODO don't use Serialization for the whole struct! + # It almost works to deserialize from just the fields and type, + # but that ends up being problematic for + # anonymous functions (the type changes between serialization & deserialization) + tio = IOBuffer() + serialize(tio, struct_type) + encode( + io, + Tag( + CUSTOM_LANGUAGE_TYPE, + [string("Julia/", T), take!(tio), fields2array(struct_type)] + ) + ) +end diff --git a/src/logger.jl b/src/logger.jl new file mode 100755 index 0000000..5efd3ff --- /dev/null +++ b/src/logger.jl @@ -0,0 +1,72 @@ +#= +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +struct RembusLogger <: AbstractLogger + io::IO + groups::Vector{Symbol} +end + +function repl_log() + ConsoleLogger(stdout, Info, meta_formatter=repl_metafmt) |> global_logger +end + +function repl_metafmt(level::LogLevel, _module, group, id, file, line) + @nospecialize + color = Logging.default_logcolor(level) + prefix = string(level == Warn ? "Warning" : string(level), ':') + suffix::String = "" + return color, prefix, suffix +end + +function logging(; debug=[]) + groups = [] + for item in debug + isa(item, Module) && push!(CONFIG.debug_modules, item) + isa(item, Symbol) && push!(groups, item) + end + + if CONFIG.log === "stdout" + RembusLogger(stdout, groups) |> global_logger + else + RembusLogger(open(CONFIG.log, "a+"), groups) |> global_logger + end + + return nothing +end + +function Logging.min_enabled_level(logger::RembusLogger) + Logging.Debug +end + +function Logging.shouldlog( + logger::RembusLogger, + level, + _module, + group, + id +) + level >= Logging.Info || group in logger.groups || _module in CONFIG.debug_modules +end + +function Logging.handle_message( + logger::RembusLogger, + level, + message, + _module, + group, + id, + file, + line; + kwargs... +) + println(logger.io, "[$(now())][$_module][$(Threads.threadid())][$level] $message") + if haskey(kwargs, :exception) + e, bt = kwargs[:exception] + showerror(logger.io, e, bt) + end + flush(logger.io) +end diff --git a/src/precompile.jl b/src/precompile.jl new file mode 100644 index 0000000..b11e956 --- /dev/null +++ b/src/precompile.jl @@ -0,0 +1,205 @@ +#= +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +# When compiling responses are delayed a litte bit +ENV["REMBUS_TIMEOUT"] = "60" + +df = DataFrame(name=["trento", "belluno"], score=[10, 50]) +bdf = DataFrame(x=1:10, y=1:10) + +mutable struct TestBag + noarg_message_received::Bool + msg_received::Int +end + +function noarg() +end + +function mytopic(bag::TestBag, data::Number) + @debug "mytopic recv: $data" +end + +function mytopic(bag::TestBag, data) + @debug "df:\n$(view(data, 1:2, :))" +end + +function publish_macroapi(publisher, sub1; waittime=1) + testbag = TestBag(false, 0) + + @component sub1 + @shared sub1 testbag + @subscribe sub1 mytopic from_now + @reactive sub1 + + sleep(waittime / 3) + + @component publisher + @publish publisher mytopic(2) + @publish publisher mytopic(df) + + @publish publisher noarg() + + sleep(waittime / 2) + + for cli in [publisher, sub1] + @terminate cli + end +end + +function publish_api(pub, sub1; waittime=1) + mytopic_topic = "mytopic_topic" + noarg_topic = "noarg" + + testbag = TestBag(false, 0) + + publisher = connect(pub) + + sub1 = connect(sub1) + shared(sub1, testbag) + + subscribe(sub1, mytopic_topic, mytopic) + reactive(sub1) + + publish(publisher, mytopic_topic, 2) + publish(publisher, mytopic_topic, df) + publish(publisher, mytopic_topic, bdf) + + publish(publisher, noarg_topic) + + sleep(waittime) + unsubscribe(sub1, mytopic_topic) + + sleep(waittime / 4) + + for cli in [publisher, sub1] + close(cli) + end +end + +function add_one(add_one_arg) + add_one_arg + 1 +end + +function do_method_error(data) + reason = "this is an error" + error(reason) +end + +function do_args_error(data) + args_error_msg = "expected a float32" + if !isa(data, Float32) + throw(ErrorException(args_error_msg)) + end +end + +function rpc_method(rpc_method_arg) +end + +function request_api(request_url, exposer_url) + rpc_topic = "rpc_method" + request_arg = 1 + + client = connect(request_url) + + try + rpc(client, rpc_topic, exceptionerror=true) + catch e + end + + implementor = connect(exposer_url) + expose(implementor, rpc_topic, add_one) + + res = rpc(client, rpc_topic, request_arg) + + expose(implementor, rpc_topic, do_method_error) + try + res = rpc(client, rpc_topic) + catch e + end + + for cli in [implementor, client] + close(cli) + end +end + +mutable struct Holder + valuemap::Vector + Holder() = new([ # type of value received => value sent + UInt8 => Int(1), + UInt8 => 255, + UInt16 => 256, + UInt32 => 65536, + Int8 => -1, + Int16 => -129, + Int32 => -40000, + Float32 => Float32(1.2), + Float64 => Float64(1.2), + DataFrame => DataFrame(:a => [1, 2]), + String => "foo", + Dict => Dict(1 => 2), + Vector => [1, 2] + ]) +end + +function type_consumer(bag, n) + @debug "[type_consumer]: recv $n ($(typeof(n)))" +end + +function types() + bag = Holder() + REQUESTOR = "tcp://:8001/type_publisher" + TYPE_LISTENER = "type_listener" + + @component REQUESTOR + @component TYPE_LISTENER + + sleep(0.1) + + @subscribe TYPE_LISTENER type_consumer from_now + @shared TYPE_LISTENER bag + @reactive TYPE_LISTENER + + sleep(0.1) + for (typ, msg) in bag.valuemap + sleep(0.05) + @publish REQUESTOR type_consumer(msg) + end + sleep(2) + @terminate REQUESTOR + @terminate TYPE_LISTENER +end + +@rpc version() +@rpc uptime() +@terminate + +types() + +##try +## for exposer_url in ["zmq://:8002/test_request_impl", "test_request_impl"] +## for request_url in ["zmq://:8002/test_request", "test_request"] +## @debug "rpc endpoints: $exposer_url, $subscriber_url, $request_url" +## request_api(request_url, exposer_url) +## end +## end +##catch e +## @error e +## showerror(stdout, e, catch_backtrace()) +##end +## +try + waittime = 0.5 + for sub1 in ["tcp://:8001/sub_tcp", "zmq://:8002/sub_zmq"] + for publisher in ["tcp://:8001/pub", "zmq://:8002/pub"] + publish_macroapi(publisher, sub1, waittime=waittime) + publish_api(publisher, sub1, waittime=waittime) + end + end +catch e + @error "precompile: $e" + showerror(stdout, e, catch_backtrace()) +end diff --git a/src/protocol.jl b/src/protocol.jl new file mode 100644 index 0000000..b23d1de --- /dev/null +++ b/src/protocol.jl @@ -0,0 +1,134 @@ +abstract type RembusMsg end + +struct PingMsg <: RembusMsg + id::UInt128 + cid::String + PingMsg(id::UInt128, cid::String) = new(id, cid) + PingMsg(cid::String) = new(id(), cid) +end + +struct IdentityMsg <: RembusMsg + id::UInt128 + cid::String + IdentityMsg(userid::AbstractString) = new(id(), userid) + IdentityMsg(msgid::UInt128, userid::AbstractString) = new(msgid, userid) +end + +mutable struct PubSubMsg{T} <: RembusMsg + topic::String + data::T + flags::UInt8 + hash::UInt128 + + function PubSubMsg(topic, data=nothing, flags=0x0, hash=0) + return new{typeof(data)}(topic, data, flags, hash) + end +end + +Base.show(io::IO, message::PubSubMsg) = show(io, message.topic) + +struct RpcReqMsg{T} <: RembusMsg + id::UInt128 + topic::String + data::T + target::Union{Nothing,String} + flags::UInt8 + + function RpcReqMsg(topic::AbstractString, data, target=nothing, flags=0x0) + return new{typeof(data)}(id(), topic, data, target, flags) + end + + function RpcReqMsg( + msgid::UInt128, + topic::AbstractString, + data, + target=nothing, + flags=0x0 + ) + return new{typeof(data)}(msgid, topic, data, target, flags) + end +end + +Base.show(io::IO, message::RpcReqMsg) = show(io, message.topic) + +struct AdminReqMsg{T} <: RembusMsg + id::UInt128 + topic::String + data::T + flags::UInt8 + + function AdminReqMsg(msgid::UInt128, topic, data, flags=0x0) + return new{typeof(data)}(msgid, topic, data, flags) + end + + function AdminReqMsg(topic::String, data, flags=0x0) + return new{typeof(data)}(id(), topic, data, flags) + end +end + +struct AckMsg <: RembusMsg + hash::UInt128 +end + +struct ResMsg{T} <: RembusMsg + id::UInt128 + status::UInt8 + data::T + flags::UInt8 + + ResMsg(id, status, data, flags=0x0) = new{typeof(data)}(id, status, data, flags) + + function ResMsg(req::RpcReqMsg, status::UInt8, data=nothing, flags=0x0) + return new{typeof(data)}(req.id, status, data, flags) + end + + function ResMsg(exec::RpcReqMsg, flags=0x0) + return new{typeof(exec.data)}(exec.id, exec.status, exec.data, flags) + end +end + +struct Register <: RembusMsg + id::UInt128 + cid::String # client name + userid::String + pubkey::Vector{UInt8} + Register( + msgid::UInt128, + cid::AbstractString, + userid::AbstractString, + pubkey::Vector{UInt8}) = new(msgid, cid, userid, pubkey) +end + +struct Unregister <: RembusMsg + id::UInt128 + cid::String # client name + Unregister(cid::String) = new(id(), cid) + Unregister(msgid::UInt128, cid::String) = new(msgid, cid) +end + +struct Attestation <: RembusMsg + id::UInt128 + cid::String # client name + signature::Vector{UInt8} + + Attestation(cid::AbstractString, signature::Vector{UInt8}) = new(id(), cid, signature) + + function Attestation(msgid::UInt128, cid::AbstractString, signature::Vector{UInt8}) + return new(msgid, cid, signature) + end +end + +# Message for notifying the broker that the component is closing the socket. +# Apply to ZeroMQ protocol. +struct Close <: RembusMsg +end + +# Remove the twin of a component from the broker. +# Apply to ZeroMQ protocol. +struct Remove <: RembusMsg +end + +id() = uuid4().value # unique message identifier + +isresponse(msg::RembusMsg) = false +isresponse(msg::ResMsg) = true diff --git a/src/register.jl b/src/register.jl new file mode 100644 index 0000000..f8a0c88 --- /dev/null +++ b/src/register.jl @@ -0,0 +1,93 @@ +#= +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +""" + create_private_key(cid::AbstractString) + +Create a private key for `cid` component and return its public key. +""" +function create_private_key(cid::AbstractString) + file = "$(pkfile(cid)).tmp" + cmd = `openssl genrsa -out $file 2048 ` + Base.run(cmd) + + Vector{UInt8}(read(`openssl rsa -in $file -outform der -pubout`, String)) +end + +""" + register(cid::AbstractString) + +Register the client identified by `cid`. +""" +function register(cid::AbstractString, userid::AbstractString, pin::AbstractString) + cmp = Component(cid) + + kfile = pkfile(cmp.id) + + if isfile(kfile) + error("$cid component: found private key $kfile") + end + + @debug "connecting register" + process = NullProcess(cmp.id) + rb = RBConnection(cmp) + _connect(rb, process) + + try + @debug "registering $cid" + pubkey = create_private_key(cmp.id) + + value = parse(Int, pin, base=16) + msgid = id() & 0xffffffffffffffffffffffff00000000 + value + + msg = Register(msgid, cmp.id, userid, pubkey) + response = wait_response(rb, msg, request_timeout()) + if (response.status != STS_SUCCESS) + rembuserror(code=response.status, reason=response.data) + end + # finally save the key + mv("$(kfile).tmp", kfile) + + catch e + rm("$(kfile).tmp", force=true) + rethrow() + finally + close(rb) + end +end + + +""" + unregister(cid::AbstractString) + +Unregister the client identified by `cid`. + +The secret pin is not needed because only an already connected and authtenticated +component may execute the unregister command. +""" +function unregister(rb, cid::AbstractString) + @debug "unregistering $cid" + + msg = Unregister(cid) + response = wait_response(rb, msg, request_timeout()) + if (response.status != STS_SUCCESS) + rembuserror(code=response.status) + end + + # remove the private key + rm(pkfile(cid), force=true) +end + +function transport_send(router, ws, msg::Register) + pkt = [TYPE_REGISTER, id2bytes(msg.id), msg.cid, msg.userid, msg.pubkey] + transport_write(ws, pkt) +end + +function transport_send(router, ws, msg::Unregister) + pkt = [TYPE_UNREGISTER, id2bytes(msg.id), msg.cid] + transport_write(ws, pkt) +end diff --git a/src/store.jl b/src/store.jl new file mode 100755 index 0000000..016285e --- /dev/null +++ b/src/store.jl @@ -0,0 +1,417 @@ +#= +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +""" + load_owners() + +Return the owners dataframe +""" +function load_owners() + fn = joinpath(CONFIG.db, "owners.csv") + if isfile(fn) + DataFrame(CSV.File(fn, types=[String, String, String, Bool])) + else + @debug "owners.csv not found, only unauthenticated users allowed" + DataFrame(pin=String[], uid=String[], name=[], enabled=Bool[]) + end +end + +""" + save_owners(owners_df) + +Save the owners table. +""" +function save_owners(owners_df) + fn = joinpath(CONFIG.db, "owners.csv") + CSV.write(fn, owners_df) +end + +""" + load_token_app() + +Return the token_app dataframe +""" +function load_token_app() + fn = joinpath(CONFIG.db, "token_app.csv") + if isfile(fn) + df = DataFrame(CSV.File(fn, types=Dict(1 => String, 2 => String))) + return df + else + @debug "token_app.csv not found" + DataFrame(uid=String[], app=String[]) + end +end + +""" + save_token_app(df) + +Save the owners table. +""" +function save_token_app(df) + fn = joinpath(CONFIG.db, "token_app.csv") + CSV.write(fn, df) +end + +function twindir() + if !isdir(CONFIG.db) + mkdir(CONFIG.db) + end + twin_dir = joinpath(CONFIG.db, "twins") + if !isdir(twin_dir) + mkdir(twin_dir) + end + twin_dir +end + +function save_table(router_tbl, filename) + if !isdir(CONFIG.db) + mkdir(CONFIG.db) + end + table = Dict() + for (topic, twins) in router_tbl + twin_ids = [tw.id for tw in twins if tw.hasname] + table[topic] = twin_ids + end + fn = joinpath(CONFIG.db, filename) + open(fn, "w") do io + write(io, JSON3.write(table)) + end +end + +function save_impl_table(router) + @debug "saving impls table" + save_table(router.topic_impls, "impls.json") +end + +function save_topic_auth_table(router) + @debug "saving topic_auth table" + fn = joinpath(CONFIG.db, "topic_auth.json") + + d = Dict() + for (topic, cids) in router.topic_auth + d[topic] = keys(cids) + end + + open(fn, "w") do io + write(io, JSON3.write(d)) + end +end + +function save_admins(router) + @debug "saving admins" + fn = joinpath(CONFIG.db, "admins.json") + open(fn, "w") do io + write(io, JSON3.write(router.admins)) + end +end + +function save_pubkey(cid::AbstractString, pubkey) + fn = joinpath(CONFIG.db, "apps", cid) + open(fn, "w") do io + write(io, pubkey) + end +end + +function remove_pubkey(cid::AbstractString) + fn = joinpath(CONFIG.db, "apps", cid) + if isfile(fn) + rm(fn) + end +end + +function pubkey_file(cid::AbstractString) + fn = joinpath(CONFIG.db, "apps", cid) + + if isfile(fn) + return fn + else + error("auth failed: unknown $cid") + end +end + +isregistered(cid::AbstractString) = isfile(joinpath(CONFIG.db, "apps", cid)) + +function load_impl_table(router) + try + @debug "loading impls table" + ## load_table(router, "impls_table", router.topic_impls, "impls.json") + fn = joinpath(CONFIG.db, "impls.json") + if isfile(fn) + content = read(fn, String) + table = JSON3.read(content, Dict) + for (topic, twin_ids) in table + twins = Set{Twin}() + for tid in twin_ids + twin = create_twin(tid, router) + push!(twins, twin) + end + if !isempty(twins) + router.topic_impls[topic] = twins + end + end + end + catch e + @error "[impls.json] load failed: $e" + end +end + +function load_topic_auth_table(router) + @debug "loading topic_auth table" + fn = joinpath(CONFIG.db, "topic_auth.json") + if isfile(fn) + content = read(fn, String) + topics = Dict() + for (private_topic, cids) in JSON3.read(content, Dict) + topics[private_topic] = Dict(cids .=> true) + end + router.topic_auth = topics + end +end + +function load_admins(router) + @debug "loading admins" + fn = joinpath(CONFIG.db, "admins.json") + if isfile(fn) + content = read(fn, String) + router.admins = JSON3.read(content, Set) + end +end + +""" + load_twins(router) + +Instantiates twins that have one or more interests. +""" +function load_twins(router) + fn = joinpath(CONFIG.db, "twins.json") + if isfile(fn) + content = read(fn, String) + twin_topicsdict = JSON3.read(content, Dict) + else + twin_topicsdict = Dict() + end + + twins = Dict() + for (cid, topicsdict) in twin_topicsdict + twin = create_twin(cid, router) + twin.hasname = true + twin.retroactive = topicsdict + + for topic in keys(topicsdict) + if haskey(twins, topic) + push!(twins[topic], twin) + else + twins[topic] = Set([twin]) + end + end + end + + for (topic, twins) in twins + router.topic_interests[topic] = twins + end + + twins_dir = joinpath(CONFIG.db, "twins") + if isdir(twins_dir) + # each twin has a file with status and setting data + files = readdir(twins_dir, join=true) + for twin_id in files + try + @debug "loading twin [$twin_id]" + if isfile(twin_id) + tid = basename(twin_id) + + # delete the unknown twin + if !haskey(router.id_twin, tid) + @info "deleting [$twin_id]: unknown twin [$tid]" + rm(twin_id) + continue + end + end + catch e + @error "[$twin_id] load failed: $e" + end + end + end + +end + +""" + save_twins(router) + +Persist twins to storage. + +Save twins configuration only if twin has a name. + +Persist undelivered messages if they are queued in memory. +""" +function save_twins(router) + if !isdir(CONFIG.db) + mkdir(CONFIG.db) + end + twin_dir = joinpath(CONFIG.db, "twins") + if !isdir(twin_dir) + mkdir(twin_dir) + end + + twin_cfg = Dict{String,Dict{String,Bool}}() + for (twin_id, twin) in router.id_twin + if twin.hasname + twin_finalize(CONFIG.broker_ctx, twin) + delete!(router.id_twin, twin_id) + twin_cfg[twin_id] = twin.retroactive + end + end + fn = joinpath(CONFIG.db, "twins.json") + open(fn, "w") do io + write(io, JSON3.write(twin_cfg)) + end +end + +function park(ctx::Any, twin::Twin, msg::RembusMsg) + if !twin.hasname + # do not persist messages addressed to anonymous components + return + end + + try + if twin.mfile === nothing + tdir = twindir() + fn = joinpath(tdir, twin.id) + twin.mfile = open(fn, "a") + end + io = transport_file_io(msg) + write(twin.mfile, io.data) + flush(twin.mfile) + catch e + @error "[$twin] park_message: $e" + showerror(stdout, e, catch_backtrace()) + @showerror e + end +end + +function test_file(file::String) + count = 0 + tdir = twindir() + fn = joinpath(tdir, file) + if isfile(fn) + open(fn, "r") do f + while !eof(f) + msg = getmsg(f) + count += 1 + end + end + end + @info "messages: $count" +end + + +function getmsg(f) + lens = read(f, 4) + len::UInt32 = lens[1] + Int(lens[2]) << 8 + Int(lens[3]) << 16 + Int(lens[4]) << 24 + content = read(f, len) + + io = IOBuffer(maxsize=len) + write(io, content) + seekstart(io) + llmsg = decode(io) + msg = PubSubMsg(llmsg[1], llmsg[2]) + return msg +end + +function upload_to_queue(twin) + try + tdir = twindir() + fn = joinpath(tdir, twin.id) + if isfile(fn) + open(fn, "r") do f + while !eof(f) + msg = getmsg(f) + @mlog("[$(twin.id)] <- $(prettystr(msg))") + enqueue!(twin.mq, msg) + end + end + if twin.mfile !== nothing + close(twin.mfile) + twin.mfile = nothing + end + # finally delete the file + @debug "deleting enqueued messages file [$fn]" + rm(fn) + end + catch e + @error "upload_to_queue: $e" + @showerror e + end +end + +function unpark(ctx::Any, twin::Twin) + count = 0 + try + tdir = twindir() + fn = joinpath(tdir, twin.id) + if isfile(fn) + open(fn, "r") do f + while !eof(f) + msg = getmsg(f) + count += 1 + @mlog("[$(twin.id)] <- $(prettystr(msg))") + retro = get(twin.retroactive, msg.topic, true) + if retro + if (count % 5000) == 0 + # pause a little to give time at the receiver to + # get the ack messages + sleep(0.1) + end + transport_send(twin, twin.sock, msg) + else + @debug "[$twin] retroactive=$(retro): skipping msg $msg" + end + end + end + @debug "[$twin] sent $count cached msgs" + if twin.mfile !== nothing + close(twin.mfile) + twin.mfile = nothing + end + # finally delete the file + @debug "deleting enqueued messages file [$fn]" + rm(fn) + #mv(fn, "$fn.sav", force=true) + end + catch e + @error "[$twin] unpark_messages: $e" + @showerror e + end +end + +""" + save_configuration(router::Router) + +Persist router configuration on disk. +""" +function save_configuration(router::Router) + callback_or(router, :save_configuration) do + @debug "saving configuration on disk" + save_impl_table(router) + save_topic_auth_table(router) + save_admins(router) + save_twins(router) + end +end + +function load_configuration(router) + callback_or(router, :load_configuration) do + @debug "loading configuration from disk" + load_twins(router) + load_impl_table(router) + load_topic_auth_table(router) + load_admins(router) + + router.owners = load_owners() + router.token_app = load_token_app() + end +end diff --git a/src/transport.jl b/src/transport.jl new file mode 100755 index 0000000..6182182 --- /dev/null +++ b/src/transport.jl @@ -0,0 +1,788 @@ +#= +SPDX-License-Identifier: AGPL-3.0-only + +Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com +Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com +=# + +const DATA_EMPTY = UInt8[0xf6] + +const HEADER_LEN1 = 0x81 +const HEADER_LEN2 = 0x8D +const HEADER_LEN4 = 0x8F + +""" + zmq_load(socket::ZMQ.Socket) + +Get a Rembus message from a ZeroMQ multipart message. + +The decoding is performed at the client side. +""" +function zmq_load(socket::ZMQ.Socket) + + pkt = zmq_message(socket) + + header = pkt.header + data::Vector{UInt8} = pkt.data + + type = header[1] + ptype = type & 0x3f + flags = type & 0xc0 + if ptype == TYPE_PUB + topic = header[2] + h = UInt128(hash(topic)) + UInt128(hash(data)) << 64 + msg = PubSubMsg(topic, dataframe_if_tagvalue(decode(data)), flags, h) + elseif ptype == TYPE_RPC + id = bytes2id(header[2]) + topic = header[3] + target = header[4] + msg = RpcReqMsg(id, topic, dataframe_if_tagvalue(decode(data)), target, flags) + elseif ptype == TYPE_RESPONSE + id = bytes2id(header[2]) + status = header[3] + # NOTE: for very large dataframes decode is slow, needs investigation. + try + val = decode(data) + return ResMsg(id, status, dataframe_if_tagvalue(val), flags) + catch e + return ResMsg(id, STS_GENERIC_ERROR, "$e", flags) + end + end + msg +end + +""" + connected_socket_load(pkt) + +Get a Rembus message from a CBOR encoded packet. + +The decoding is performed at the client side. +""" +function connected_socket_load(pkt) + payload = decode(pkt) + + ptype = payload[1] & 0x3f + flags = payload[1] & 0xc0 + if ptype == TYPE_PUB + h = UInt128(hash(payload[2])) + UInt128(hash(payload[3])) << 64 + data = dataframe_if_tagvalue(payload[3]) + # topic data + return PubSubMsg(payload[2], data, flags, h) + elseif ptype == TYPE_RPC + if length(payload) == 5 + data = dataframe_if_tagvalue(payload[5]) + else + data = dataframe_if_tagvalue(payload[4]) + end + # id topic data + return RpcReqMsg(bytes2id(payload[2]), payload[3], data) + elseif ptype == TYPE_RESPONSE + data = dataframe_if_tagvalue(payload[4]) + # id status data + return ResMsg(bytes2id(payload[2]), payload[3], data, flags) + end +end + +""" + broker_parse(pkt) + +Get a Rembus message from a CBOR encoded packet. + +The decoding is performed at the broker side. +""" +function broker_parse(pkt) + payload = decode(pkt) + + ptype = payload[1] & 0x3f + flags = payload[1] & 0xc0 + if ptype == TYPE_IDENTITY + cid = payload[3] + @debug "< handle_ack_timeout( + tim, twin, msg, msgid + ), ACK_WAIT_TIME) + end + + transport_send(ws, msg) +end + +function transport_send(ws, msg::PubSubMsg) + content = tagvalue_if_dataframe(msg.data) + pkt = [TYPE_PUB | msg.flags, msg.topic, message2data(content)] + transport_write(ws, pkt) +end + +function transport_send(socket::ZMQ.Socket, msg::PubSubMsg) + content = tagvalue_if_dataframe(msg.data) + send(socket, Message(), more=true) + send(socket, encode([TYPE_PUB | msg.flags, msg.topic]), more=true) + send(socket, encode(content), more=true) + send(socket, MESSAGE_END, more=false) +end + +function transport_send(twin::Twin, socket::ZMQ.Socket, msg::PubSubMsg) + address = twin.router.twin2address[twin.id] + data = data2message(msg.data) + + if twin.qos === with_ack + msg.flags |= 0x80 + msgid = UInt128(hash(msg.topic)) + UInt128(hash(data)) << 64 + twin.acktimer[msgid] = Timer((tim) -> handle_ack_timeout( + tim, twin, msg, msgid + ), ACK_WAIT_TIME) + end + + send(socket, address, more=true) + send(socket, Message(), more=true) + send(socket, encode([TYPE_PUB | msg.flags, msg.topic]), more=true) + send(socket, data, more=true) + send(socket, MESSAGE_END, more=false) +end + +transport_send(::Twin, ws, msg::RpcReqMsg) = transport_send(ws, msg::RpcReqMsg) + +function transport_send(ws, msg::RpcReqMsg) + content = tagvalue_if_dataframe(msg.data) + if msg.target === nothing + pkt = [ + TYPE_RPC | msg.flags, + id2bytes(msg.id), + msg.topic, + nothing, + message2data(content) + ] + else + pkt = [ + TYPE_RPC | msg.flags, + id2bytes(msg.id), + msg.topic, + msg.target, + message2data(content) + ] + end + transport_write(ws, pkt) +end + +function transport_send(twin::Twin, socket::ZMQ.Socket, msg::RpcReqMsg) + address = twin.router.twin2address[twin.id] + send(socket, address, more=true) + send(socket, Message(), more=true) + send(socket, encode([TYPE_RPC, id2bytes(msg.id), msg.topic, nothing]), more=true) + send(socket, data2message(msg.data), more=true) + send(socket, MESSAGE_END, more=false) +end + +function transport_send(socket::ZMQ.Socket, msg::RpcReqMsg) + content = tagvalue_if_dataframe(msg.data) + send(socket, Message(), more=true) + + if msg.target === nothing + target = nothing + else + target = msg.target + end + + send(socket, encode([TYPE_RPC, id2bytes(msg.id), msg.topic, target]), more=true) + send(socket, encode(msg.data), more=true) + send(socket, MESSAGE_END, more=false) +end + +function transport_send(twin::Twin, socket::ZMQ.Socket, msg::ResMsg, enc=false) + address = twin.router.mid2address[msg.id] + send(socket, address, more=true) + + send(socket, Message(), more=true) + send(socket, encode([TYPE_RESPONSE, id2bytes(msg.id), msg.status]), more=true) + if enc + data = encode(msg.data) + else + data = data2message(msg.data) + end + send(socket, data, more=true) + send(socket, MESSAGE_END, more=false) +end + +function transport_send(socket::ZMQ.Socket, msg::ResMsg) + send(socket, Message(), more=true) + send(socket, encode([TYPE_RESPONSE, id2bytes(msg.id), msg.status]), more=true) + send(socket, encode(msg.data), more=true) + send(socket, MESSAGE_END, more=false) +end + +transport_send(::Twin, ws, msg::ResMsg, enc=false) = transport_send(ws, msg::ResMsg, enc) + +function transport_send(ws, msg::ResMsg, ::Bool=false) + content = tagvalue_if_dataframe(msg.data) + pkt = [TYPE_RESPONSE | msg.flags, id2bytes(msg.id), msg.status, message2data(content)] + transport_write(ws, pkt) +end + +transport_send(::Twin, ws, msg::AdminReqMsg) = transport_send(ws, msg::AdminReqMsg) + +function transport_send(ws, msg::AdminReqMsg) + content = tagvalue_if_dataframe(msg.data) + pkt = [TYPE_ADMIN | msg.flags, id2bytes(msg.id), msg.topic, content] + transport_write(ws, pkt) +end + +function transport_send(socket::ZMQ.Socket, msg::AdminReqMsg) + if isa(msg.data, DataFrame) + content = tagvalue_if_dataframe(msg.data) + else + content = msg.data + end + send(socket, Message(), more=true) + send(socket, encode([TYPE_ADMIN, id2bytes(msg.id), msg.topic]), more=true) + send(socket, encode(content), more=true) + send(socket, MESSAGE_END, more=false) +end + +transport_send(::Twin, ws, msg::AckMsg) = transport_send(ws, msg::AckMsg) + +function transport_send(ws, msg::AckMsg) + pkt = [TYPE_ACK, id2bytes(msg.hash)] + transport_write(ws, pkt) +end + +function transport_send(socket::ZMQ.Socket, msg::AckMsg) + send(socket, Message(), more=true) + send(socket, encode([TYPE_ACK, id2bytes(msg.hash)]), more=true) + send(socket, DATA_EMPTY, more=true) + send(socket, MESSAGE_END, more=false) +end + +transport_send(::Twin, ws, msg::Attestation) = transport_send(ws, msg::Attestation) + +function transport_send(ws, msg::Attestation) + pkt = [TYPE_ATTESTATION, id2bytes(msg.id), msg.cid, msg.signature] + transport_write(ws, pkt) +end + +function transport_send(socket::ZMQ.Socket, msg::Attestation) + send(socket, Message(), more=true) + send(socket, encode([TYPE_ATTESTATION, id2bytes(msg.id), msg.cid]), more=true) + send(socket, msg.signature, more=true) + send(socket, MESSAGE_END, more=false) +end + +transport_send(::Twin, ws, msg::Register) = transport_send(ws, msg::Register) + +function transport_send(ws, msg::Register) + pkt = [TYPE_REGISTER, id2bytes(msg.id), msg.cid, msg.userid, msg.pubkey] + transport_write(ws, pkt) +end + +function transport_send(socket::ZMQ.Socket, msg::Register) + send(socket, Message(), more=true) + send(socket, encode([TYPE_REGISTER, id2bytes(msg.id), msg.cid, msg.userid]), more=true) + send(socket, msg.pubkey, more=true) + send(socket, MESSAGE_END, more=false) +end + +transport_send(::Twin, ws, msg::Unregister) = transport_send(ws, msg::Unregister) + +function transport_send(ws, msg::Unregister) + pkt = [TYPE_UNREGISTER, id2bytes(msg.id), msg.cid] + transport_write(ws, pkt) +end + +function transport_send(socket::ZMQ.Socket, msg::Unregister) + send(socket, Message(), more=true) + send(socket, encode([TYPE_UNREGISTER, id2bytes(msg.id), msg.cid]), more=true) + send(socket, DATA_EMPTY, more=true) + send(socket, MESSAGE_END, more=false) +end + +function transport_send(socket::ZMQ.Socket, ::Close) + send(socket, Message(), more=true) + send(socket, encode([TYPE_CLOSE]), more=true) + send(socket, DATA_EMPTY, more=true) + send(socket, MESSAGE_END, more=false) +end + +function transport_send(socket::ZMQ.Socket, ::Remove) + send(socket, Message(), more=true) + send(socket, encode([TYPE_REMOVE]), more=true) + send(socket, DATA_EMPTY, more=true) + send(socket, MESSAGE_END, more=false) +end + +function tagvalue_if_dataframe(data) + if isa(data, Vector) + result = [] + + for el in data + if isa(el, DataFrame) + aio = IOBuffer() + Arrow.write(aio, el) + push!(result, Tag(DATAFRAME_TAG, aio.data)) + else + push!(result, el) + end + end + return result + elseif isa(data, DataFrame) + aio = IOBuffer() + Arrow.write(aio, data) + + return Tag(DATAFRAME_TAG, aio.data) + end + + return data +end + +function dataframe_if_tagvalue(buffer) + if isa(buffer, Vector) + result = [] + + for el in buffer + if isa(el, Tag) && el.id == DATAFRAME_TAG + @debug "assuming that element is a DataFrame" + push!(result, DataFrame(Arrow.Table(IOBuffer(el.data)))) + else + push!(result, el) + end + end + return result + else + if isa(buffer, Tag) && buffer.id == DATAFRAME_TAG + @debug "assuming that data is a DataFrame" + return DataFrame(Arrow.Table(IOBuffer(buffer.data))) + end + end + + return buffer +end + +function transport_write(ws::WebSockets.WebSocket, pkt) + payload = encode(pkt) + @rawlog("out: $payload") + try + lock(websocketlock) do + HTTP.WebSockets.send(ws, payload) + end + catch e + throw(RembusDisconnect()) + end +end + +function transport_write(sock, llmsg) + payload = encode(llmsg) + len = length(payload) + if len < 256 + header = vcat(HEADER_LEN1, UInt8(len)) + elseif len < 65536 + header = vcat(HEADER_LEN2, UInt8((len >> 8) & 0xFF), UInt8(len & 0xFF)) + else + header = vcat( + HEADER_LEN4, + UInt8((len >> 24) & 0xFF), + UInt8((len >> 16) & 0xFF), + UInt8((len >> 8) & 0xFF), + UInt8(len & 0xFF) + ) + end + + io = IOBuffer(maxsize=length(header) + len) + write(io, header) + write(io, payload) + @rawlog("out: $(io.data)") + write(sock, io.data) + flush(sock) +end + +function transport_read(sock::MbedTLS.SSLContext) + while true + headers = read(sock, 1) + if isempty(headers) + MbedTLS.ssl_session_reset(sock) + throw(ConnectionClosed()) + end + type = headers[1] + if type === HEADER_LEN1 + len = read(sock, 1)[1] + elseif type === HEADER_LEN2 + lb = read(sock, 2) + len = Int(lb[1]) << 8 + lb[2] + elseif type === HEADER_LEN4 + lb = read(sock, 4) + len = Int(lb[1]) << 24 + Int(lb[2]) << 16 + Int(lb[3]) << 8 + lb[4] + else + @error "tcp channel invalid header format" + throw(ConnectionClosed()) + end + payload = read(sock, len) + @rawlog("in: $payload") + return payload + end +end + +function transport_read(sock::TCPSocket) + while true + headers = read(sock, 1) + if isempty(headers) + throw(ConnectionClosed()) + end + type = headers[1] + if type === HEADER_LEN1 + len = read(sock, 1)[1] + elseif type === HEADER_LEN2 + lb = read(sock, 2) + len = Int(lb[1]) << 8 + lb[2] + elseif type === HEADER_LEN4 + lb = read(sock, 4) + len = Int(lb[1]) << 24 + Int(lb[2]) << 16 + Int(lb[3]) << 8 + lb[4] + else + @error "tcp channel invalid header value [$type]" + throw(ConnectionClosed()) + end + payload = read(sock, len) + @rawlog("in: $payload") + return payload + end +end + +function transport_read(socket::WebSockets.WebSocket) + d = HTTP.WebSockets.receive(socket) + @rawlog("in: $d ($(typeof(d)))") + d +end + +function isconnectionerror(ws::WebSockets.WebSocket, e) + isa(e, EOFError) || isa(e, Base.IOError) || !WebSockets.isok(ws) +end + +function isconnectionerror(ws, e) + return isa(e, EOFError) || isa(e, Base.IOError) || isa(e, ConnectionClosed) +end + +Base.isopen(ws::WebSockets.WebSocket) = Base.isopen(ws.io) diff --git a/test/ack/ack_common.jl b/test/ack/ack_common.jl new file mode 100755 index 0000000..1782011 --- /dev/null +++ b/test/ack/ack_common.jl @@ -0,0 +1,73 @@ +include("../utils.jl") + +test_topic = "acktopic" + +function consume(data) + global count + global ts + + count += 1 + #if (count % 10000) == 0 + if (count % 10000) == 0 + delta = time() - ts + @info "*** $count records received in $delta secs" + end + #print(".") +end + +function storm(pub) + global ts + ts = time() + @info "sending" + for i in 1:num_msg + if (i % 5000) == 0 + sleep(0.01) + end + publish(pub, test_topic, i) + end + @info "done" +end + +function run(publisher, consumer) + global count + count = 0 + + #sleep(2) + pub = connect(publisher) + sub = connect(consumer) + enable_ack(sub) + + sleep(0.1) + reactive(sub) + + subscribe(sub, test_topic, consume, true) + + @async storm(pub) + + sleep(2) + + # close and connect again + close(sub) + sleep(2) + + @debug "reopening $consumer" _group = :test + sub = connect(consumer) + enable_ack(sub) + #sub = connect("zmq://10.220.18.228:8002/test_ack_sub") + + subscribe(sub, test_topic, consume, true) + reactive(sub) + + @info "sleeping" + sleep(15) + for cli in [pub, sub] + @info "closing $cli" + close(cli) + end + #sleep(5) + @info "end" +end + +## # for jit +## comp = connect("compile_component") +## close(comp) diff --git a/test/ack/test_ws_ack.jl b/test/ack/test_ws_ack.jl new file mode 100755 index 0000000..525abc4 --- /dev/null +++ b/test/ack/test_ws_ack.jl @@ -0,0 +1,12 @@ +include("ack_common.jl") + +publisher = "test_ack_pub" +consumer = "test_ack_sub" +num_msg = 100000 + +count = 0 + +execute_caronte_process(() -> run(publisher, consumer), "test_ws_ack") + +@info "[test_ws_ack] received $count messages" +@test num_msg <= count < num_msg + 100 \ No newline at end of file diff --git a/test/ack/test_zmq_ack.jl b/test/ack/test_zmq_ack.jl new file mode 100755 index 0000000..c96f503 --- /dev/null +++ b/test/ack/test_zmq_ack.jl @@ -0,0 +1,12 @@ +include("ack_common.jl") + +publisher = "zmq://:8002/test_ack_pub" +consumer = "zmq://:8002/test_ack_sub" +num_msg = 100000 + +count = 0 + +execute(() -> run(publisher, consumer), "test_zmq_ack") + +@info "[test_zmq_ack] received $count messages" +@test num_msg <= count < num_msg + 20000 \ No newline at end of file diff --git a/test/api/test_mixed.jl b/test/api/test_mixed.jl new file mode 100644 index 0000000..a4f64b6 --- /dev/null +++ b/test/api/test_mixed.jl @@ -0,0 +1,48 @@ +include("../utils.jl") + +using DataFrames + +mutable struct TestHolder + request_arg::String + TestHolder() = new("") +end + +df = DataFrame("col" => ["a", "b"], "val" => [1, 2]) + +function mymethod(arg) + @debug "[exposer] arg: $arg" _group = :test + df +end + +function mymethod(bag, arg) + @debug "[subscriber]: received $arg" _group = :test + bag.request_arg = arg +end + +function run() + @debug "starting ..." _group = :test + bag = TestHolder() + + @component "exposer" + @component "zmq://:8002/client" + @component "zmq://:8002/subscriber" + + @expose "exposer" mymethod + + @subscribe "subscriber" mymethod + @shared "subscriber" bag + @reactive "subscriber" + + invalue = "pippo" + result = @rpc "client" mymethod(invalue) + @debug "rpc result = $result" _group = :test + @test result == df + @test bag.request_arg == invalue + + @terminate "client" + @terminate "subscriber" + @terminate "exposer" + +end + +execute(run, "test_mixed") diff --git a/test/api/test_publish.jl b/test/api/test_publish.jl new file mode 100644 index 0000000..7d4ed28 --- /dev/null +++ b/test/api/test_publish.jl @@ -0,0 +1,124 @@ +using DataFrames + +include("../utils.jl") + +df = DataFrame(name=["trento", "belluno"], score=[10, 50]) +bdf = DataFrame(x=1:10, y=1:10) + +mutable struct TestBag + noarg_message_received::Bool + msg_received::Int +end + +function inspect(bag::TestBag) + bag.noarg_message_received = true +end + +function consume(bag::TestBag, data::Number) + bag.msg_received += 1 + + @debug "consume recv: $data" _group = :test + @atest data == 2 "consume(data=$data): expected data == 2" +end + +function consume(bag::TestBag, data) + bag.msg_received += 1 + + @debug "df:\n$(view(data, 1:2, :))" _group = :test + if names(data) == ["x", "y"] + @atest size(data) == size(bdf) "data:$(size(data)) == bdf:$(size(bdf))" + else + @atest size(data) == size(df) "data:$(size(data)) == df:$(size(df))" + end +end + + +function publish_workflow(pub, sub1, sub2, sub3, isfirst=false) + waittime = 0.3 + my_topic = "my_topic" + noarg_topic = "noarg_topic" + + testbag = TestBag(false, 0) + + publisher = connect(pub) + + sub1 = connect(sub1) + + shared(sub1, testbag) + + subscribe(sub1, my_topic, consume) + + sub2 = connect(sub2) + shared(sub2, testbag) + + subscribe(sub2, my_topic, consume) + subscribe(sub2, noarg_topic, inspect) + + # sub3 is not reactive: no messages delivered to sub3 + sub3 = connect(sub3) + shared(sub3, testbag) + subscribe(sub3, my_topic, consume, true) + + publish(publisher, my_topic, 2) + publish(publisher, my_topic, df) + publish(publisher, my_topic, bdf) + # publish with no args + try + publish(publisher, noarg_topic) + catch e + @test false + end + + sleep(waittime) + unsubscribe(sub1, my_topic) + + # removing a not registerd interest throws an error + try + unsubscribe(sub1, my_topic) + @test 0 == 1 + catch e + @debug "[remove interest]: $e" _group = :test + @test isa(e, Rembus.RembusError) + @test e.code === Rembus.STS_GENERIC_ERROR + end + + #if isfirst + # sleep(1) + #else + sleep(waittime / 4) + #end + + if isfirst + @info "testbag.msg_received $(testbag.msg_received) === 9" + else + @test testbag.msg_received === 9 + end + + for cli in [publisher, sub1, sub2, sub3] + close(cli) + end + + if isfirst + @info "testbag.noarg_message_received $(testbag.noarg_message_received) === true" + else + @test testbag.noarg_message_received === true + end +end + +function run() + publish_workflow("pub", "tcp://:8001/sub1", "tcp://:8001/sub2", "sub3", true) + + for sub1 in ["tcp://:8001/sub1", "zmq://:8002/sub1"] + for sub2 in ["tcp://:8001/sub2", "zmq://:8002/sub2"] + for sub3 in ["sub3", "zmq://:8002/sub3"] + for publisher in ["pub", "zmq://:8002/pub"] + @debug "test_publish endpoints: $sub1, $sub2, $sub3, $publisher, " _group = :test + publish_workflow(publisher, sub1, sub2, sub3) + testsummary() + end + end + end + end +end + +execute(run, "test_publish") diff --git a/test/api/test_publish_macros.jl b/test/api/test_publish_macros.jl new file mode 100755 index 0000000..47004f8 --- /dev/null +++ b/test/api/test_publish_macros.jl @@ -0,0 +1,115 @@ +using DataFrames + +include("../utils.jl") + +df = DataFrame(name=["trento", "belluno"], score=[10, 50]) +bdf = DataFrame(x=1:10) +bdf.y = bdf.x .^ 2 + +function noarg(ctx) + ctx.noarg_message_received = true +end + +function mytopic(ctx, data::Number) + ctx.msg_received += 1 + @atest data == 2 "mytopic(data=$data): expected data == 2" +end + +function mytopic(ctx, data) + ctx.msg_received += 1 + if names(data) == ["x", "y"] + @atest size(data) == size(bdf) "data:$(size(data)) == bdf:$(size(bdf))" + else + @atest size(data) == size(df) "data:$(size(data)) == df:$(size(df))" + end +end + +function publish_workflow(publisher, sub1, sub2, sub3; waittime=1, testholder=missing) + @component sub1 + + @subscribe sub1 mytopic from_now + + if testholder !== missing + @shared sub1 testholder + end + + @reactive sub1 + + @component sub2 + @reactive sub2 + + @subscribe sub2 mytopic from_now + if testholder !== missing + @shared sub2 testholder + end + + @subscribe sub2 noarg from_now + + # sub3 is not reactive: no messages delivered to sub3 + @component sub3 + + @subscribe sub3 mytopic before_now + if testholder !== missing + @shared sub3 testholder + end + + sleep(waittime / 3) + + @component publisher + @publish publisher mytopic(2) + @publish publisher mytopic(df) + @publish publisher mytopic(bdf) + + # publish with no args + try + @publish publisher noarg() + catch e + testholder !== missing && @test false + end + + sleep(waittime) + + @unsubscribe sub1 mytopic + + #removing a not registered interest throws an error + try + @unsubscribe sub1 mytopic + catch e + @test isa(e, Rembus.RembusError) + @test e.code === Rembus.STS_GENERIC_ERROR + end + + sleep(waittime / 2) + testholder !== missing && @test testholder.msg_received === 9 + + for cli in [publisher, sub1, sub2, sub3] + @terminate cli + end +end + +Rembus.CONFIG.zmq_ping_interval = 0 + +mutable struct TestHolder + noarg_message_received::Bool + msg_received::Int +end + +function run() + waittime = 0.8 + for sub1 in ["tcp://:8001/sub1", "zmq://:8002/sub1"] + for sub2 in ["tcp://:8001/sub2", "zmq://:8002/sub2"] + for sub3 in ["sub3", "zmq://:8002/sub3"] + for publisher in ["pub", "zmq://:8002/pub"] + @debug "test_publish endpoints: $sub1, $sub2, $sub3, $publisher" _group = :test + testholder = TestHolder(false, 0) + publish_workflow(publisher, sub1, sub2, sub3, waittime=waittime, testholder=testholder) + testsummary() + @test testholder.noarg_message_received === true + waittime = 0.4 + end + end + end + end +end + +execute(run, "test_publish_macros") diff --git a/test/api/test_request.jl b/test/api/test_request.jl new file mode 100755 index 0000000..ef90b27 --- /dev/null +++ b/test/api/test_request.jl @@ -0,0 +1,127 @@ +include("../utils.jl") + +rpc_topic = "rpc_method" +request_arg = 1 +reason = "this is an error" +args_error_msg = "expected a float32" + +mutable struct TestBag + rpc_method_invoked::Bool +end + +function add_one(add_one_arg) + @atest add_one_arg == request_arg + add_one_arg + 1 +end + +function do_method_error(data) + error(reason) +end + +function do_args_error(data) + if !isa(data, Float32) + throw(ErrorException(args_error_msg)) + end +end + +function rpc_method(bag, rpc_method_arg) + bag.rpc_method_invoked = true + + # expect rpc_method_arg equals to request arg + @atest rpc_method_arg == request_arg +end + +function run(request_url, subscriber_url, exposer_url) + bag = TestBag(false) + client = tryconnect(request_url) + + try + rpc(client, rpc_topic, exceptionerror=true) + @test 0 == 1 + catch e + @test isa(e, Rembus.RpcMethodNotFound) + @test e.cid === client.client.id + @test e.topic === rpc_topic + end + + implementor = tryconnect(exposer_url) + expose(implementor, rpc_topic, add_one) + + subscriber = tryconnect(subscriber_url) + shared(subscriber, bag) + reactive(subscriber) + subscribe(subscriber, rpc_topic, rpc_method) + + res = rpc(client, rpc_topic, request_arg) + @test res == 2 + + try + res = rpc(implementor, rpc_topic, exceptionerror=true) + @test 0 == 1 + catch e + @test isa(e, Rembus.RpcMethodLoopback) + @test e.cid === implementor.client.id + @test e.topic === rpc_topic + end + + expose(implementor, rpc_topic, do_method_error) + try + res = rpc(client, rpc_topic) + @test 0 == 1 + catch e + @test isa(e, Rembus.RpcMethodException) + @test e.cid === client.client.id + @test e.topic === rpc_topic + end + + expose(implementor, rpc_topic, do_args_error) + try + res = rpc(client, rpc_topic) + @test 0 == 1 + catch e + @test isa(e, Rembus.RpcMethodException) + @test e.cid === client.client.id + @test e.topic === rpc_topic + end + + close(implementor) + try + res = rpc(client, rpc_topic, timeout=2) + @test 0 == 1 + catch e + if isa(e, Rembus.RpcMethodUnavailable) + @test e.cid === client.client.id + @test e.topic === rpc_topic + else + @warn "unexpected exception: $e" + @test false + end + end + + @test bag.rpc_method_invoked === true + + implementor = tryconnect(exposer_url) + unexpose(implementor, rpc_topic) + + for cli in [implementor, client, subscriber] + close(cli) + end + sleep(0.000001) +end + +Rembus.CONFIG.zmq_ping_interval = 0 +Rembus.CONFIG.ws_ping_interval = 0 + +function run() + for exposer_url in ["zmq://:8002/test_request_impl", "test_request_impl"] + for subscriber_url in ["zmq://:8002/test_request_sub", "test_request_sub"] + for request_url in ["zmq://:8002/test_request", "test_request"] + @debug "rpc endpoints: $exposer_url, $subscriber_url, $request_url" _group = :test + run(request_url, subscriber_url, exposer_url) + testsummary() + end + end + end +end + +execute(run, "test_request_api") diff --git a/test/api/test_types.jl b/test/api/test_types.jl new file mode 100644 index 0000000..5a3b679 --- /dev/null +++ b/test/api/test_types.jl @@ -0,0 +1,78 @@ +include("../utils.jl") + +using DataFrames + +mutable struct TestHolder + handler_called::Int + noarg_message_received::Bool + valuemap::Vector + TestHolder() = new(0, false, [ # type of value received => value sent + UInt8 => Int(1), + UInt8 => 255, + UInt16 => 256, + UInt32 => 65536, + Int8 => -1, + Int16 => -129, + Int32 => -40000, + Float32 => Float32(1.2), + Float64 => Float64(1.2), + DataFrame => DataFrame(:a => [1, 2]), + String => "foo", + Dict => Dict(1 => 2), + Vector => [1, 2] + ]) +end + +function foo(n) + n + 1 +end + +function foo(bag, n) + @debug "[foo]: recv $n" _group = :test +end + +function bar(bag, n) + @debug "[bar]: recv $n ($(typeof(n)))" _group = :test + bag.handler_called += 1 + @atest isa(n, bag.valuemap[bag.handler_called].first) "$n isa $(bag.valuemap[bag.handler_called].first)" +end + +function run() + exposer = "test_types_exposer" + requestor = "test_types_cli" + listener = "test_types_listener" + + bag = TestHolder() + + @component exposer + @component requestor + @component listener + + sleep(0.1) + + @subscribe listener foo from_now + @shared listener bag + @reactive listener + + @expose exposer foo + sleep(0.1) + + result = @rpc requestor foo(1) + @debug "[$requestor] result=$result" _group = :test + @test result == 2 + + @subscribe exposer bar from_now + @shared exposer bag + @reactive exposer + + sleep(0.1) + for (typ, msg) in bag.valuemap + sleep(0.05) + @publish requestor bar(msg) + end + + sleep(1) + @test bag.handler_called == length(bag.valuemap) +end + +execute(run, "test_types") diff --git a/test/api/test_zmq.jl b/test/api/test_zmq.jl new file mode 100644 index 0000000..ee4b1e8 --- /dev/null +++ b/test/api/test_zmq.jl @@ -0,0 +1,38 @@ +include("../utils.jl") + +using DataFrames + +df = DataFrame("col" => ["a", "b"], "val" => [1, 2]) +function mymethod(arg) + @debug "[mymethod] arg: $arg" _group = :test + df +end + +function consume(arg) + @debug "[sub]: received $arg" _group = :test +end + +function run() + @component "zmq://:8002/exposer" + @component "zmq://:8002/subscriber" + @component "zmq://:8002/client" + + @expose "exposer" mymethod + sleep(0.5) + + result = @rpc "client" mymethod(DataFrame("x" => 1:3)) + @debug "rpc result = $result" _group = :test + @test result == df + + @subscribe "subscriber" consume + @reactive "subscriber" + + @publish "client" consume("hello") + @publish "client" consume(df) + + @terminate "client" + @terminate "subscriber" + @terminate "exposer" +end + +execute(run, "test_zmq") \ No newline at end of file diff --git a/test/auth/test_register.jl b/test/auth/test_register.jl new file mode 100644 index 0000000..e998e43 --- /dev/null +++ b/test/auth/test_register.jl @@ -0,0 +1,60 @@ +include("../utils.jl") + +using DataFrames + +function init(uid, pin) + df = DataFrame(pin=String[pin], uid=String[uid], name=["Test"], enabled=Bool[true]) + if !isdir(Rembus.CONFIG.db) + mkdir(Rembus.CONFIG.db) + end + Rembus.save_owners(df) +end + +function run() + cmp = Rembus.Component(url) + + Rembus.register(url, uid, pin) + + # check configuration + # token_app file contains the app component + df = Rembus.load_token_app() + @debug "token_app: $df" _group = :test + @test df[df.app.==cmp.id, :app][1] === cmp.id + @test df[df.app.==cmp.id, :uid][1] === uid + + # private key was created + @test isfile(Rembus.pkfile(cmp.id)) + + # public key was provisioned + fname = Rembus.pubkey_file(cmp.id) + @test basename(fname) === cmp.id + + client = tryconnect(url) + + try + Rembus.unregister(client, cmp.id) + + df = Rembus.load_token_app() + + # the app component was removed from token_app file + @test isempty(df[df.app.==cmp.id, :]) + + # the public key was removed + @test_throws ErrorException Rembus.pubkey_file(cmp.id) + + # the private key was removed + @test isfile(Rembus.pkfile(cmp.id)) === false + catch + @test 0 == 1 + rethrow() + finally + close(client) + end +end + +uid = "rembus_user" +url = "zmq://:8002/regcomp" +pin = "11223344" + +setup() = init(uid, pin) +execute(run, "test_register", setup=setup) diff --git a/test/connect/test_connect.jl b/test/connect/test_connect.jl new file mode 100755 index 0000000..67e9fde --- /dev/null +++ b/test/connect/test_connect.jl @@ -0,0 +1,13 @@ +include("../utils.jl") + +function run() + for cid in ["tcp://:8001/aaa", "ws://:8000/bbb", "zmq://:8002/ccc"] + rb = connect(cid) + @test isconnected(rb) === true + + close(rb) + @test !isconnected(rb) === true + end +end + +execute(run, "test_connect") diff --git a/test/connect/test_tls_connect.jl b/test/connect/test_tls_connect.jl new file mode 100755 index 0000000..f3c1386 --- /dev/null +++ b/test/connect/test_tls_connect.jl @@ -0,0 +1,20 @@ +include("../utils.jl") + +function run() + for cid in ["tls://:8001/aaa", "wss://:8000/bbb"] + rb = connect(cid) + + @test isconnected(rb) === true + + close(rb) + @test !isconnected(rb) === true + end +end + +# client env +ENV["HTTP_CA_BUNDLE"] = joinpath(Rembus.keystore_dir(), "rembus-ca.crt") + +args = Dict("secure" => true) +execute(run, "test_tls_connect", args=args) + +delete!(ENV, "HTTP_CA_BUNDLE") diff --git a/test/coverage.jl b/test/coverage.jl new file mode 100755 index 0000000..6b0bde7 --- /dev/null +++ b/test/coverage.jl @@ -0,0 +1,10 @@ +using Pkg +using Coverage + +Pkg.test("Rembus", coverage=true) +coverage = process_folder() +LCOV.writefile("lcov.info", coverage) + +for dir in ["src", "test"] + foreach(rm, filter(endswith(".cov"), readdir(dir, join=true))) +end \ No newline at end of file diff --git a/test/embedded/test_embedded.jl b/test/embedded/test_embedded.jl new file mode 100644 index 0000000..dcbaeb3 --- /dev/null +++ b/test/embedded/test_embedded.jl @@ -0,0 +1,84 @@ +include("../utils.jl") + +using DataFrames + +function df_service(session) + DataFrame(x=1:10) +end + +function rpc_service(session, x, y) + return x + y +end + +function rpc_fault(session, x::Integer, y::Integer) + return x // y +end + +smoke_message = "ola" +received = false + +function signal(session, name) + global received + @atest name == smoke_message "expected name == $smoke_message" + received = true +end + + +function start_server() + emb = Rembus.Embedded() + provide(emb, df_service) + provide(emb, rpc_service) + provide(emb, rpc_fault) + provide(emb, signal) + Rembus.serve(emb, wait=false, exit_when_done=false) +end + + +function run() + try + start_server() + sleep(2) + result = @rpc rpc_service(1, 2) + @test result == 3 + + df = @rpc df_service() + @test isa(df, DataFrame) + + try + @rpc rpc_service(1) + @test false + catch e + @info "[test_embedded] rpc_service error: $e" + @test isa(e, Rembus.RpcMethodException) + end + + try + result = @rpc rpc_fault(0, 0) + @test false + catch e + @info "[test_embedded] rpc_fault error: $e" + @test isa(e, Rembus.RpcMethodException) + end + + @publish signal(smoke_message) + @terminate + + rb = connect() + publish(rb, "signal", smoke_message) + + # if an error on the embedded side occurred the connection is closed + @test Rembus.isconnected(rb) + + close(rb) + catch e + @error "[test_embedded] error: $e" + @test false + finally + shutdown() + end + +end + +run() +@test received +testsummary() diff --git a/test/integration/test_process_fault.jl b/test/integration/test_process_fault.jl new file mode 100644 index 0000000..a9f4421 --- /dev/null +++ b/test/integration/test_process_fault.jl @@ -0,0 +1,43 @@ +include("../utils.jl") + +using Distributed +using Visor + +restarts = 0 + +function trace(supervisor, msg) + global restarts + if isa(msg, Visor.ProcessError) + restarts += 1 + end +end + +function run() + # Component Under Test + cid = "test_process" + + caronte_reset() + + Visor.trace_event = trace + + @async supervise([rembus(cid)], intensity=3) + sleep(3) + + remotecall(Rembus.caronte, 2, exit_when_done=false) + sleep(2) +end + +try + @info "[test_process_fault] start" + run() + @debug "task restarts: $restarts" _group = :test + @test 1 <= restarts <= 2 +catch e + @error "[test_process_fault]: $e" + showerror(stdout, e, catch_backtrace()) +finally + shutdown() + remotecall(Visor.shutdown, 2) + sleep(3) + @info "[test_process_fault] stop" +end \ No newline at end of file diff --git a/test/integration/test_retroactive.jl b/test/integration/test_retroactive.jl new file mode 100755 index 0000000..e3460ce --- /dev/null +++ b/test/integration/test_retroactive.jl @@ -0,0 +1,54 @@ +include("../utils.jl") + + +#RECEIVED = false + +mutable struct TestBag + broadcast_received::Bool +end + +function consume(bag, data) + @debug "[test retroactive] received: $data" _group = :test + bag.broadcast_received = true +end + +function run() + topic = "mytopic" + bag = TestBag(false) + + client = connect("test_retroactive_pub") + subscriber = connect("test_retroactive_sub") + shared(subscriber, bag) + reactive(subscriber) + subscribe(subscriber, topic, consume, true) + + close(subscriber) + + publish(client, topic, "lost in dev/null") + + subscriber = connect("test_retroactive_sub") + shared(subscriber, bag) + subscribe(subscriber, topic, consume, false) + reactive(subscriber) + + sleep(0.2) + @test bag.broadcast_received === false + + close(subscriber) + publish(client, topic, UInt8(1)) + + subscriber = connect("test_retroactive_sub") + shared(subscriber, bag) + subscribe(subscriber, topic, consume, true) + reactive(subscriber) + + sleep(0.2) + @test bag.broadcast_received === true + + for cli in [client, subscriber] + close(cli) + end + shutdown() +end + +execute(run, "test_retroactive") diff --git a/test/integration/test_round_robin.jl b/test/integration/test_round_robin.jl new file mode 100755 index 0000000..1fb085f --- /dev/null +++ b/test/integration/test_round_robin.jl @@ -0,0 +1,85 @@ +include("../utils.jl") + +rpc_topic = "rpc_method" +request_arg = 1 + +function rpc_server1(rpc_method_arg) + return rpc_method_arg + 1 +end + +function rpc_server2(rpc_method_arg) + return rpc_method_arg + 2 + +end + +function rpc_server3(rpc_method_arg) + return rpc_method_arg + 3 +end + +function run(client_url, server1_url, server2_url, server3_url) + client = connect(client_url) + + server1 = connect(server1_url) + expose(server1, rpc_topic, rpc_server1) + + server2 = connect(server2_url) + expose(server2, rpc_topic, rpc_server2) + + server3 = connect(server3_url) + expose(server3, rpc_topic, rpc_server3) + + res = rpc(client, rpc_topic, request_arg) + @test res == 2 + + res = rpc(client, rpc_topic, request_arg) + @info "[round_robin]: result=$res" + @test res == 3 + + res = rpc(client, rpc_topic, request_arg) + @info "[round_robin]: result=$res" + @test res == 4 + + close(server1) + + res = rpc(client, rpc_topic, request_arg) + # server1 down, expect a response from server2 + @info "[round_robin]: result=$res" + @test res == 3 + + close(server2) + for i in 1:2 + res = rpc(client, rpc_topic, request_arg) + # server1 down, expect a response from server2 + @info "[round_robin]: result=$res" + @test res == 4 + end + + close(server3) + try + res = rpc(client, rpc_topic, request_arg) + catch e + @info "[round_robin]: $e" + end + + server1 = connect(server1_url) + expose(server1, rpc_topic, rpc_server1) + + res = rpc(client, rpc_topic, request_arg) + @info "[round_robin]: result=$res" + @test res == 2 + + for cli in [client, server1] + close(cli) + end + sleep(0.000001) +end + +ENV["BROKER_BALANCER"] = "round_robin" + +function run() + run("rr_client", "rr_server_1", "rr_server_2", "rr_server_3") + testsummary() +end + +execute(run, "test_round_robin") +ENV["BROKER_BALANCER"] = "first_up" \ No newline at end of file diff --git a/test/integration/test_wrong_balancer.jl b/test/integration/test_wrong_balancer.jl new file mode 100644 index 0000000..835c889 --- /dev/null +++ b/test/integration/test_wrong_balancer.jl @@ -0,0 +1,15 @@ +include("../utils.jl") + +ENV["BROKER_BALANCER"] = "wrong_balancer" + +tmr = Timer((tmr) -> shutdown(), 5) +try + Rembus.caronte(wait=true, exit_when_done=false) + @test false +catch e + @test e.msg === "wrong balancer, must be one of first_up, less_busy, round_robin" +finally + close(tmr) +end + +ENV["BROKER_BALANCER"] = "first_up" diff --git a/test/integration/test_zmq_protocol_errors.jl b/test/integration/test_zmq_protocol_errors.jl new file mode 100644 index 0000000..edc2acb --- /dev/null +++ b/test/integration/test_zmq_protocol_errors.jl @@ -0,0 +1,60 @@ +include("../utils.jl") + +using ZMQ + +struct InvalidMsg <: Rembus.RembusMsg + id::UInt128 + InvalidMsg() = new(Rembus.id()) +end + +struct PartialMsg <: Rembus.RembusMsg + id::UInt128 + PartialMsg() = new(Rembus.id()) +end + +function Rembus.transport_send(socket::ZMQ.Socket, msg::InvalidMsg) + send(socket, Message(), more=true) + send(socket, encode([63]), more=true) + send(socket, "aaaa", more=true) + send(socket, Rembus.MESSAGE_END, more=false) +end + +function Rembus.transport_send(socket::ZMQ.Socket, msg::PartialMsg) + send(socket, Message(), more=true) + send(socket, encode([Rembus.TYPE_PUB, "mytopic"]), more=true) + send(socket, encode("my_value"), more=false) +end + +function mymethod(arg) + @debug "[mymethod] arg: $arg" _group = :test + 1 +end + +function consume(arg) + @debug "[sub]: received $arg" _group = :test +end + +function run() + rb = Rembus.connect("zmq://:8002/test_zmq_error") + + try + @debug "sending a corrupted packet" _group = :test + Rembus.rpcreq(rb, InvalidMsg(), exceptionerror=true, timeout=2) + @test false + catch e + @debug "expected error: $(e.msg)" _group = :test + @test isa(e, Rembus.RembusTimeout) + end + + version = Rembus.rpc(rb, "version") + @test version === Rembus.VERSION + + Rembus.rembus_write(rb, PartialMsg()) + + version = Rembus.rpc(rb, "version") + @test version === Rembus.VERSION + + close(rb) +end + +execute(run, "test_zmq_error") \ No newline at end of file diff --git a/test/private/test_private_topic.jl b/test/private/test_private_topic.jl new file mode 100644 index 0000000..425f527 --- /dev/null +++ b/test/private/test_private_topic.jl @@ -0,0 +1,79 @@ +using JSON3 + +include("../utils.jl") + +mutable struct TestHolder + msg_received::Int + TestHolder() = new(0) +end + +function consume(bag, data) + bag.msg_received += 1 +end + +function setup(name) + # add admin privilege to client with name equals to test_private + fn = joinpath(Rembus.CONFIG.db, "admins.json") + open(fn, "w") do io + write(io, JSON3.write(Set([name]))) + end +end + +function run(authorized_component) + bag = TestHolder() + + priv_topic = "foo" + myproducer = "myproducer" + myconsumer = "myconsumer" + myunauth = "myunauth" + + rb = tryconnect(authorized_component) + + private_topic(rb, priv_topic) + authorize(rb, myproducer, priv_topic) + authorize(rb, myconsumer, priv_topic) + + producer = connect(myproducer) + + unauth_consumer = connect(myunauth) + consumer = connect(myconsumer) + for c in [unauth_consumer, consumer] + shared(c, bag) + end + + try + subscribe(unauth_consumer, priv_topic, consume) + reactive(unauth_consumer) + catch e + @debug "expected error: $e" _group = :test + @test isa(e, Rembus.RembusError) + @test e.code === Rembus.STS_GENERIC_ERROR + end + + subscribe(consumer, priv_topic, consume) + reactive(consumer) + + publish(producer, priv_topic, "some_data") + + sleep(0.2) + for c in [rb, producer, consumer, unauth_consumer] + close(c) + end + @test bag.msg_received === 1 +end + +authorized_component = "test_private" + +setup() = setup(authorized_component) + +execute(() -> run(authorized_component), "test_private_topic", setup=setup) + +#@info "[test_private_topic] start" +#try +# run() +# @test msg_received === 1 +# testsummary() +#finally +# shutdown() +#end +#@info "[test_private_topic] stop" diff --git a/test/runtests.jl b/test/runtests.jl new file mode 100644 index 0000000..dac282b --- /dev/null +++ b/test/runtests.jl @@ -0,0 +1,107 @@ +using Distributed +addprocs(2) + +@everywhere using Rembus +@everywhere using Test +using SafeTestsets +using Visor + +const GROUP = get(ENV, "GROUP", "unit") + +Rembus.CONFIG = Rembus.Settings() +Rembus.CONFIG.db = "/tmp/caronte_test" + +mkpath(joinpath(Rembus.CONFIG.db, "apps")) + +@testset "Rembus" begin + @testset "unit" begin + if GROUP == "all" || GROUP == "unit" + @time @safetestset "twin" begin + include("unit/test_twin.jl") + end + @time @safetestset "cbor" begin + include("unit/test_cbor.jl") + end + @time @safetestset "component" begin + include("unit/test_component.jl") + end + @time @safetestset "signature" begin + include("unit/test_signature.jl") + end + @time @safetestset "balancer_round_robin" begin + include("unit/test_round_robin.jl") + end + @time @safetestset "balancer_less_busy" begin + include("unit/test_less_busy.jl") + end + end + if GROUP == "all" || GROUP == "private" + @time @safetestset "private_topic" begin + include("private/test_private_topic.jl") + end + end + if GROUP == "all" || GROUP == "embedded" + @time @safetestset "embedded" begin + include("embedded/test_embedded.jl") + end + end + if GROUP == "ack" + @time @safetestset "ws_ack" begin + include("ack/test_ws_ack.jl") + end + @time @safetestset "zmq_ack" begin + include("ack/test_zmq_ack.jl") + end + end + if GROUP == "all" || GROUP == "connect" + @time @safetestset "tls_connect" begin + include("connect/test_tls_connect.jl") + end + @time @safetestset "connect" begin + include("connect/test_connect.jl") + end + end + if GROUP == "all" || GROUP == "integration" + @time @safetestset "retroactive" begin + include("integration/test_retroactive.jl") + end + @time @safetestset "process_fault" begin + include("integration/test_process_fault.jl") + end + @time @safetestset "zmq_protocol_errors" begin + include("integration/test_zmq_protocol_errors.jl") + end + @time @safetestset "round_robin" begin + include("integration/test_round_robin.jl") + end + @time @safetestset "wrong_balancer" begin + include("integration/test_wrong_balancer.jl") + end + end + if GROUP == "all" || GROUP == "api" + @time @safetestset "publish_api" begin + include("api/test_publish.jl") + end + @time @safetestset "publish_macros" begin + include("api/test_publish_macros.jl") + end + @time @safetestset "request_api" begin + include("api/test_request.jl") + end + @time @safetestset "types" begin + include("api/test_types.jl") + end + @time @safetestset "zmq" begin + include("api/test_zmq.jl") + end + @time @safetestset "mixed" begin + include("api/test_mixed.jl") + end + end + if GROUP == "all" || GROUP == "auth" + @time @safetestset "register" begin + include("auth/test_register.jl") + end + end + end +end diff --git a/test/unit/test_cbor.jl b/test/unit/test_cbor.jl new file mode 100644 index 0000000..028bb6b --- /dev/null +++ b/test/unit/test_cbor.jl @@ -0,0 +1,22 @@ +using Rembus +using Test + +# decode a simple value +buff = UInt8[0xe1] +@test decode(buff) == 1 + +n = Int32(1) +e = encode(n) +@test e == UInt8[0x1a, 0, 0, 0, 0x1] + +n = Int(1) +e = encode(n) +@test e == UInt8[0x1b, 0, 0, 0, 0, 0, 0, 0, 0x1] + +n = Int(-1) +e = encode(n) +@test e == UInt8[0x3b, 0, 0, 0, 0, 0, 0, 0, 0] + +n = Int8(-1) +e = encode(n) +@test e == UInt8[0x20] diff --git a/test/unit/test_component.jl b/test/unit/test_component.jl new file mode 100644 index 0000000..17725c6 --- /dev/null +++ b/test/unit/test_component.jl @@ -0,0 +1,39 @@ +using Rembus +using Test + +ENV["REMBUS_BASE_URL"] = "tcp://caronte.org:8001" + +c = Rembus.Component("myc") +@test c.port == 8001 +@test c.host == "caronte.org" +@test c.protocol == :tcp +@test Rembus.brokerurl(c) == "tcp://caronte.org:8001" + +c = Rembus.Component("zmq://caronte.org:8002/myc") +@test c.id == "myc" +@test c.protocol == :zmq +@test c.host == "caronte.org" +@test Rembus.brokerurl(c) == "tcp://caronte.org:8002" + +c = Rembus.Component("zmq://:8002/myc") +@test c.id == "myc" +@test c.protocol == :zmq +@test c.host == "caronte.org" +@test Rembus.brokerurl(c) == "tcp://caronte.org:8002" + +delete!(ENV, "REMBUS_BASE_URL") + +c = Rembus.Component("myc") +@test c.port == 8000 +@test c.host == "127.0.0.1" +@test c.protocol == :ws +@test Rembus.brokerurl(c) == "ws://127.0.0.1:8000" + +c = Rembus.Component("ws://127.0.0.1:8000") +@test c.id == "rembus" +@test c.port == 8000 +@test c.host == "127.0.0.1" +@test c.protocol == :ws +@test Rembus.brokerurl(c) == "ws://127.0.0.1:8000" + +@test_throws ErrorException("wrong url xyz://host: unknown protocol xyz") Rembus.Component("xyz://host") \ No newline at end of file diff --git a/test/unit/test_less_busy.jl b/test/unit/test_less_busy.jl new file mode 100644 index 0000000..6d17a7b --- /dev/null +++ b/test/unit/test_less_busy.jl @@ -0,0 +1,44 @@ +using Rembus +using Test + +ts = 10000; +msgid = 1 +topic = "mytopic" + +struct FakeSocket +end + +sentdata(t) = Rembus.SentData( + ts, Rembus.Msg(Rembus.TYPE_PUB, Rembus.PubSubMsg(topic), t)) + +Base.isopen(socket::FakeSocket) = true + +router = Rembus.Router() + +twin1 = Rembus.Twin(router, "twin1", Channel()) +twin2 = Rembus.Twin(router, "twin2", Channel()) + +twin1.sock = FakeSocket() +twin2.sock = FakeSocket() + +implementors = [twin1, twin2] + +target = Rembus.less_busy(router, topic, implementors) +@info "target: $target" +@test target === twin1 + +twin1.sent[msgid] = sentdata(twin1) + +target = Rembus.less_busy(router, topic, implementors) + +@info "target: $target" +@test target === twin2 + +twin2.sent[msgid] = sentdata(twin2) + +twin2.sent[msgid+1] = sentdata(twin2) + +target = Rembus.less_busy(router, topic, implementors) + +@info "target: $target" +@test target === twin1 diff --git a/test/unit/test_round_robin.jl b/test/unit/test_round_robin.jl new file mode 100644 index 0000000..5f4b2e1 --- /dev/null +++ b/test/unit/test_round_robin.jl @@ -0,0 +1,74 @@ +using Rembus +using Test + +ts = 10000; +msgid = 1 +topic = "mytopic" + +struct FakeSocket +end + +sentdata(t) = Rembus.SentData( + ts, Rembus.Msg(Rembus.TYPE_PUB, Rembus.PubSubMsg(topic), t)) + +Base.isopen(socket::FakeSocket) = true + +router = Rembus.Router() + +twin1 = Rembus.Twin(router, "twin1", Channel()) +twin2 = Rembus.Twin(router, "twin2", Channel()) +twin3 = Rembus.Twin(router, "twin3", Channel()) + +twin1.sock = FakeSocket() +twin2.sock = FakeSocket() + +implementors = [twin1, twin2, twin3] +@debug "implementors: $implementors" + +target = Rembus.round_robin(router, topic, implementors) +@debug "1. target: $target" +@test target === twin1 + +target = Rembus.round_robin(router, topic, implementors) +@debug "2. target: $target" +@test target === twin2 + +target = Rembus.round_robin(router, topic, implementors) +@debug "3. target: $target" +@test target === twin1 + +target = Rembus.round_robin(router, topic, implementors) +@debug "4. target: $target" +@test target === twin2 + +twin3.sock = FakeSocket() +target = Rembus.round_robin(router, topic, implementors) +@debug "5. target: $target" +@test target === twin3 + +implementors = [twin2, twin3] +target = Rembus.round_robin(router, topic, implementors) +@debug "6. target: $target" +@test target === twin2 + +target = Rembus.round_robin(router, topic, implementors) +@debug "7. target: $target" +@test target === twin3 + +implementors = [twin3] +target = Rembus.round_robin(router, topic, implementors) +@debug "8. target: $target" +@test target === twin3 + +implementors = [] +target = Rembus.round_robin(router, topic, implementors) +@debug "9. target: $target" +@test target === nothing + +twin1.sock = nothing +twin2.sock = nothing +implementors = [twin1, twin2] +target = Rembus.round_robin(router, topic, implementors) +@debug "10. target: $target" +@test target === nothing + diff --git a/test/unit/test_signature.jl b/test/unit/test_signature.jl new file mode 100644 index 0000000..91ed292 --- /dev/null +++ b/test/unit/test_signature.jl @@ -0,0 +1,76 @@ +using Rembus +using Test + +struct FakeTwin + challenge::Vector{UInt8} + session::Dict + FakeTwin(dare) = new(dare, Dict("challenge" => dare)) +end + +function verify(cid, wrong_challenge=nothing) + rb = Rembus.RBConnection(cid) + + challenge = [0x1, 0x2, 0x3, 0x4] + Response = Rembus.ResMsg(1, Rembus.STS_SUCCESS, challenge) + + att = Rembus.attestate(rb, Response) + + if wrong_challenge === nothing + server_challenge = challenge + else + server_challenge = wrong_challenge + end + Rembus.verify_signature(FakeTwin(server_challenge), att) +end + +#cid = "test_cid" +cid = "plain_test" +secret = "pippo" + +client_fn = Rembus.pkfile(cid) +server_fn = joinpath(Rembus.CONFIG.db, "apps", cid) +@info "secret file: $client_fn" + +# create client and server files +for fn in [client_fn, server_fn] + open(fn, "w") do f + write(f, secret) + end +end + +isvalid = verify(cid) +@test isvalid + +## challenge = [0x1, 0x2, 0x3, 0x4] +## Response = Rembus.ResMsg(1, Rembus.STS_SUCCESS, challenge) +## +## att = Rembus.attestate(rb, Response) +## @info att +## isvalid = Rembus.verify_signature(FakeTwin(challenge), att) +## @info "isvalid: $isvalid" +## @test isvalid + +try + verify(cid, [0x01]) + @test false +catch e + @test e.msg == "authentication failed" +end + +cid = "private_test" + +# create private secret +client_fn = Rembus.pkfile(cid) +pubkey = Rembus.create_private_key(cid) +mv("$(client_fn).tmp", client_fn, force=true) + +# create public secret +server_fn = joinpath(Rembus.CONFIG.db, "apps", cid) +open(server_fn, "w") do f + write(f, pubkey) +end + +isvalid = verify(cid) +@test isvalid +#pubf = Rembus.pubkey_file(cid) +#@info "pubfile: $pubf" diff --git a/test/unit/test_twin.jl b/test/unit/test_twin.jl new file mode 100644 index 0000000..068b670 --- /dev/null +++ b/test/unit/test_twin.jl @@ -0,0 +1,27 @@ +using Rembus +using Test + +function task(pd, router) + router.process = pd + for msg in pd.inbox + @isshutdown(msg) + end +end + +identity = UInt8[0, 1, 2, 3, 4] +router = Rembus.Router() +proc = process("router", task, args=(router,)) + +twin = Rembus.Twin(router, "twin", Channel()) +#startup(process(Rembus.twin_task, args=(twin,))) +supervise(process(Rembus.twin_task, args=(twin,)), wait=false) + +router.address2twin[identity] = twin +router.topic_impls["topic"] = Set([twin]) + +yield() +Rembus.destroy_twin(twin, router) + +@test isempty(router.topic_impls) +@test !haskey(router.address2twin, identity) + diff --git a/test/utils.jl b/test/utils.jl new file mode 100755 index 0000000..fed8dae --- /dev/null +++ b/test/utils.jl @@ -0,0 +1,97 @@ +using HTTP +using Logging +using Rembus +using Visor +using Test + +results = [] + +macro start_caronte(init, args) + quote + running = get(ENV, "CARONTE_RUNNING", "0") !== "0" + if !running + caronte_reset() + fn = $(esc(init)) + if fn !== nothing + fn() + end + + Rembus.caronte(wait=false, exit_when_done=false, args=$(esc(args))) + end + end +end + +macro atest(expr, descr=nothing) + if descr === nothing + descr = string(expr) + end + :(push!(results, $(esc(descr)) => $(esc(expr)))) +end + +function execute_caronte_process(fn, testname; setup=nothing) + running = get(ENV, "CARONTE_RUNNING", "0") !== "0" + + if !running + p = Base.run(Cmd(`$(@__DIR__)/../bin/caronte`, detach=true), wait=false) + end + sleep(10) + + Rembus.logging(debug=[:test]) + @info "[$testname] start" + try + fn() + finally + shutdown() + sleep(3) + if !running + Base.kill(p, Base.SIGINT) + end + sleep(3) + end + @info "[$testname] stop" +end + +function execute(fn, testname; setup=nothing, args=Dict()) + @start_caronte setup args + sleep(0.5) + Rembus.logging(debug=[:test]) + @info "[$testname] start" + try + fn() + finally + shutdown() + end + @info "[$testname] stop" +end + +function testsummary() + global results + for (descr, t) in results + @debug "$descr: $(t ? "pass" : "fail")" + @test t + end + empty!(results) +end + +function tryconnect(id) + maxretries = 10 + count = 0 + while true + try + return connect(id) + catch e + if !isa(e, HTTP.Exceptions.ConnectError) + @warn "error: $e" + end + count === maxretries && rethrow(e) + count += 1 + end + sleep(1) + end +end + +function caronte_reset() + Rembus.CONFIG = Rembus.Settings() + foreach(rm, readdir(Rembus.twindir(), join=true)) + foreach(rm, filter(isfile, readdir(Rembus.CONFIG.db, join=true))) +end