diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..86a63dc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..c85790e --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + ruby: ['3.1', '3.0', '2.7', '2.6'] + + steps: + - uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Build gem + run: bundle exec rake + + - name: Run RuboCop + run: bundle exec rake rubocop + + - name: Run tests + run: bundle exec rake spec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2056c29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/rbi/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/.parlour b/.parlour new file mode 100644 index 0000000..b374e1d --- /dev/null +++ b/.parlour @@ -0,0 +1,12 @@ +parser: false + +requires: + - sord + +excluded_modules: + - OrkaAPI::AuthMiddleware + +plugins: + Sord::ParlourPlugin: + exclude_untyped: yes + rbi: yes diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..aacc24c --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,76 @@ +AllCops: + NewCops: enable + +Layout/CaseIndentation: + EnforcedStyle: end + +Layout/EndAlignment: + EnforcedStyleAlignWith: start_of_line + +Layout/FirstArrayElementIndentation: + EnforcedStyle: consistent + +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table + +Layout/LineLength: + Max: 118 + +Metrics/AbcSize: + Max: 50 + +Metrics/ClassLength: + Max: 500 + +Metrics/CyclomaticComplexity: + Max: 20 + +Metrics/MethodLength: + Max: 50 + +Metrics/ParameterLists: + CountKeywordArgs: false + +Style/AndOr: + EnforcedStyle: always + +Style/AutoResourceCleanup: + Enabled: true + +Style/CollectionMethods: + Enabled: true + +Style/MutableConstant: + EnforcedStyle: strict + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes + +Style/StringMethods: + Enabled: true + +Style/SymbolArray: + EnforcedStyle: brackets + +Style/TernaryParentheses: + EnforcedStyle: require_parentheses_when_complex + +Style/TrailingCommaInArguments: + EnforcedStyleForMultiline: comma + +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: comma + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: comma + +Style/UnlessLogicalOperators: + Enabled: true + EnforcedStyle: forbid_logical_operators diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..ef9dd96 --- /dev/null +++ b/.yardopts @@ -0,0 +1,5 @@ +--no-private +lib/orka_api_client/models/attr_predicate.rb +lib/orka_api_client/models/lazy_model.rb +lib/orka_api_client/client.rb +lib/**/*.rb diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..a04709f --- /dev/null +++ b/Gemfile @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in orka_api_client.gemspec +gemspec + +gem "parlour" +gem "rake" +gem "rspec" +gem "rubocop" +gem "rubocop-performance" +gem "rubocop-rake" +gem "rubocop-rspec" +gem "sord", git: "https://github.com/AaronC81/sord", + ref: "25c77951f8d20f73ad336e5d0eb13d0eaad14200" +gem "yard" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..117e4e4 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,101 @@ +GIT + remote: https://github.com/AaronC81/sord + revision: 25c77951f8d20f73ad336e5d0eb13d0eaad14200 + ref: 25c77951f8d20f73ad336e5d0eb13d0eaad14200 + specs: + sord (3.0.1) + commander (~> 4.5) + parlour (~> 5.0) + sorbet-runtime + yard + +PATH + remote: . + specs: + orka_api_client (0.1.0) + faraday (~> 2.0) + faraday-multipart (~> 1.0) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + commander (4.6.0) + highline (~> 2.0.0) + diff-lcs (1.5.0) + faraday (2.1.0) + faraday-net_http (~> 2.0) + ruby2_keywords (>= 0.0.4) + faraday-multipart (1.0.3) + multipart-post (>= 1.2, < 3) + faraday-net_http (2.0.1) + highline (2.0.3) + multipart-post (2.1.1) + parallel (1.21.0) + parlour (5.0.0) + commander (~> 4.5) + parser + rainbow (~> 3.0) + sorbet-runtime (>= 0.5) + parser (3.1.0.0) + ast (~> 2.4.1) + rainbow (3.1.1) + rake (13.0.6) + regexp_parser (2.2.0) + rexml (3.2.5) + rspec (3.10.0) + rspec-core (~> 3.10.0) + rspec-expectations (~> 3.10.0) + rspec-mocks (~> 3.10.0) + rspec-core (3.10.1) + rspec-support (~> 3.10.0) + rspec-expectations (3.10.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.10.0) + rspec-mocks (3.10.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.10.0) + rspec-support (3.10.3) + rubocop (1.25.0) + parallel (~> 1.10) + parser (>= 3.1.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml + rubocop-ast (>= 1.15.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.15.1) + parser (>= 3.0.1.1) + rubocop-performance (1.13.2) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + rubocop-rake (0.6.0) + rubocop (~> 1.0) + rubocop-rspec (2.8.0) + rubocop (~> 1.19) + ruby-progressbar (1.11.0) + ruby2_keywords (0.0.5) + sorbet-runtime (0.5.9566) + unicode-display_width (2.1.0) + webrick (1.7.0) + yard (0.9.27) + webrick (~> 1.7.0) + +PLATFORMS + ruby + +DEPENDENCIES + orka_api_client! + parlour + rake + rspec + rubocop + rubocop-performance + rubocop-rake + rubocop-rspec + sord! + yard + +BUNDLED WITH + 2.2.32 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b73358 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# orka_api_client + +This is a Ruby library for interacting with MacStadium's [Orka](https://www.macstadium.com/orka) API. + +⚠️⚠️⚠️ **This gem is largely untested beyond basic read-only operations. API stability is not guaranteed at this time.** ⚠️⚠️⚠️ + +## Installation + +**This gem is not yet available on RubyGems.** + +Add this line to your application's Gemfile: + +```ruby +gem 'orka_api_client', git: "https://github.com/Homebrew/orka_api_client" +``` + +And then execute: + + $ bundle install + +## Usage + +TODO: examples + +Documentation is in the `docs` folder after running `bundle exec rake yard`. + +A Sorbet RBI file is available for this gem. + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +RuboCop can be run via `bundle exec rake rubocop`. + +## Tests + +This is non-existent at the moment. Ideally this would involve a real Orka test environment, but I don't have one readily available that's not already being used for real CI. + +When they exist, `bundle exec rake spec` can be used to run the tests. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..15388bf --- /dev/null +++ b/Rakefile @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" +RSpec::Core::RakeTask.new(:spec) + +require "rubocop/rake_task" +RuboCop::RakeTask.new + +require "yard" +YARD::Rake::YardocTask.new + +task build: [:date_epoch, :parlour] +task :date_epoch do + ENV["SOURCE_DATE_EPOCH"] = IO.popen(%W[git -C #{__dir__} log -1 --format=%ct], &:read).chomp +end + +task :parlour do + system("bundle exec parlour") || abort +end + +task default: :build diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..705cd6b --- /dev/null +++ b/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "orka_api_client" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/orka_api_client.rb b/lib/orka_api_client.rb new file mode 100644 index 0000000..257f8e5 --- /dev/null +++ b/lib/orka_api_client.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "orka_api_client/version" +require_relative "orka_api_client/client" diff --git a/lib/orka_api_client/auth_middleware.rb b/lib/orka_api_client/auth_middleware.rb new file mode 100644 index 0000000..294a849 --- /dev/null +++ b/lib/orka_api_client/auth_middleware.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module OrkaAPI + # @api private + class AuthMiddleware < ::Faraday::Middleware + def initialize(app, token: nil, license_key: nil) + super(app) + + @token = token + @license_key = license_key + end + + def on_request(env) + auth_type = env.request.context&.dig(:orka_auth_type) + + Array(auth_type).each do |type| + case type + when :license + header = "orka-licensekey" + value = @license_key + when :token + header = "Authorization" + value = "Bearer #{@token}" + when nil, :none + next + else + raise AuthConfigurationError, "Invalid Orka auth type." + end + + raise AuthConfigurationError, "Missing #{type} credential." if value.nil? + + next if env.request_headers[header] + + env.request_headers[header] = value + end + end + end +end + +Faraday::Request.register_middleware(orka_auth: OrkaAPI::AuthMiddleware) diff --git a/lib/orka_api_client/client.rb b/lib/orka_api_client/client.rb new file mode 100644 index 0000000..d285ee7 --- /dev/null +++ b/lib/orka_api_client/client.rb @@ -0,0 +1,739 @@ +# frozen_string_literal: true + +require_relative "errors" +require_relative "connection" +require_relative "models/enumerator" +require_relative "models/user" +require_relative "models/vm_configuration" +require_relative "models/vm_resource" +require_relative "models/node" +require_relative "models/image" +require_relative "models/remote_image" +require_relative "models/iso" +require_relative "models/remote_iso" +require_relative "models/kube_account" +require_relative "models/log_entry" +require_relative "models/token_info" +require_relative "models/password_requirements" + +module OrkaAPI + # This is the entrypoint class for all interactions with the Orka API. + class Client + # Creates an instance of the client for a given Orka service endpoint and associated credentials. + # + # @param [String] base_url The API URL for the Orka service endpoint. + # @param [String] token The token used for authentication. This can be generated with {#create_token} from an + # credentialless client. + # @param [String] license_key The Orka license key used for authentication in administrative operations. + def initialize(base_url, token: nil, license_key: nil) + @conn = Connection.new(base_url, token: token, license_key: license_key) + @license_key = license_key + end + + # @!macro [new] lazy_enumerator + # The network operation is not performed immediately upon return of this method. The request is performed when + # any action is performed on the enumerator, or otherwise forced via {Models::Enumerator#eager}. + + # @!macro [new] lazy_object + # The network operation is not performed immediately upon return of this method. The request is performed when + # any attribute is accessed or any method is called on the returned object, or otherwise forced via + # {Models::LazyModel#eager}. Successful return from this method does not guarantee the requested resource + # exists. + + # @!macro [new] auth_none + # This method does not require the client to be configured with any credentials. + + # @!macro [new] auth_token + # This method requires the client to be configured with a token. + + # @!macro [new] auth_license + # This method requires the client to be configured with a license key. + + # @!macro [new] auth_token_and_license + # This method requires the client to be configured with both a token and a license key. + + # @!group Users + + # Retrieve a list of the users in the Orka environment. + # + # @macro auth_license + # + # @macro lazy_enumerator + # + # @return [Models::Enumerator] The enumerator of the user list. + def users + Models::Enumerator.new do + users = [] + groups = @conn.get("users") do |r| + r.options.context = { + orka_auth_type: :license, + } + end.body["user_groups"] + groups.each do |group, group_users| + group_users.each do |group_user| + users << Models::User.new( + conn: @conn, + email: group_user, + group: group, + ) + end + end + users + end + end + + # Fetches information on a particular user in the Orka environment. + # + # @macro auth_license + # + # @macro lazy_object + # + # @param [String] email The email of the user to fetch. + # @return [Models::User] The lazily-loaded user object. + def user(email) + Models::User.lazy_prepare(email: email, conn: @conn) + end + + # Create a new user in the Orka environment. You need to specify email address and password. You cannot pass an + # email address that's already in use. + # + # @macro auth_license + # + # @param [String] email An email address for the user. This also serves as the username. + # @param [String] password A password for the user. Must be at least 6 characters long. + # @param [String] group A user group for the user. Once set, you can no longer change the user group. + # @return [Models::User] The user object. + def create_user(email:, password:, group: nil) + body = { + email: email, + password: password, + group: group, + }.compact + @conn.post("users", body) do |r| + r.options.context = { + orka_auth_type: :license, + } + end + + group = "$ungrouped" if group.nil? + Models::User.new(conn: @conn, email: group_user, group: group) + end + + # Modify the email address or password of the current user. This operation is intended for regular Orka users. + # + # @macro auth_token + # + # @param [String] email The new email address for the user. + # @param [String] password The new password for the user. + # @return [void] + def update_user_credentials(email: nil, password: nil) + raise ArgumentError, "Must update either the username or password, or both." if email.nil? && password.nil? + + body = { + email: email, + password: password, + }.compact + @conn.put("users", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # @!endgroup + # @!group Tokens + + # Create an authentication token using an existing user's email and password. + # + # @macro auth_none + # + # @param [Models::User, String] user The user or their associated email address. + # @param [String] password The user's password. + # @return [String] The authentication token. + def create_token(user:, password:) + body = { + email: user_email(user), + password: password, + }.compact + @conn.post("token", body).body["token"] + end + + # Revoke the token associated with this client instance. + # + # @macro auth_token + # + # @return [void] + def revoke_token + @conn.delete("token") do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # @!endgroup + # @!group VMs + + # Retrieve a list of the VMs and VM configurations. By default this fetches resources associated with the + # client's token, but you can optionally request a list of resources for another user (or all users). + # + # If you filter by a user, or request all users, this method requires the client to be configured with both a + # token and a license key. Otherwise, it only requires a token. + # + # @macro lazy_enumerator + # + # @param [Models::User, String] user The user, or their associated email address, to use instead of the one + # associated with the client's token. Pass "all" if you wish to fetch for all users. + # @return [Models::Enumerator] The enumerator of the VM resource list. + def vm_resources(user: nil) + Models::Enumerator.new do + url = "resources/vm/list" + url += "/#{user}" unless user.nil? + resources = @conn.get(url) do |r| + auth_type = [:token] + auth_type << :license unless user.nil? + r.options.context = { + orka_auth_type: auth_type, + } + end.body["virtual_machine_resources"] + resources.map { |hash| Models::VMResource.from_hash(hash, conn: @conn, admin: !user.nil?) } + end + end + + # Fetches information on a particular VM or VM configuration. + # + # If you set the admin parameter to true, this method requires the client to be configured with both a + # token and a license key. Otherwise, it only requires a token. + # + # @macro lazy_object + # + # @param [String] name The name of the VM resource to fetch. + # @param [Boolean] admin Set to true to allow VM resources associated with other users to be queried. + # @return [Models::VMResource] The lazily-loaded VM resource object. + def vm_resource(name, admin: false) + Models::VMResource.lazy_prepare(name: name, conn: @conn, admin: admin) + end + + # Retrieve a list of the VM configurations associated with the client's token. Orka returns information about the + # base image, CPU cores, owner and name of the VM configurations. + # + # @macro auth_token + # + # @macro lazy_enumerator + # + # @return [Models::Enumerator] The enumerator of the VM configuration list. + def vm_configurations + Models::Enumerator.new do + configs = @conn.get("resources/vm/configs") do |r| + r.options.context = { + orka_auth_type: :token, + } + end.body["configs"] + configs.map { |hash| Models::VMConfiguration.from_hash(hash, conn: @conn) } + end + end + + # Fetches information on a particular VM configuration. + # + # @macro auth_token + # + # @macro lazy_object + # + # @param [String] name The name of the VM configuration to fetch. + # @return [Models::VMConfiguration] The lazily-loaded VM configuration. + def vm_configuration(name) + Models::VMConfiguration.lazy_prepare(name: name, conn: @conn) + end + + # Create a VM configuration that is ready for deployment. In Orka, VM configurations are container templates. + # You can deploy multiple VMs from a single VM configuration. You cannot modify VM configurations. + # + # @macro auth_token + # + # @param [String] name The name of the VM configuration. This string must consist of lowercase Latin alphanumeric + # characters or the dash (+-+). This string must begin and end with an alphanumeric character. This string must + # not exceed 38 characters. + # @param [Models::Image, String] base_image The name of the base image that you want to use with the + # configuration. If you want to attach an ISO to the VM configuration from which to install macOS, make sure + # that the base image is an empty disk of a sufficient size. + # @param [Models::Image, String] snapshot_image A name for the + # {https://orkadocs.macstadium.com/docs/orka-glossary#section-snapshot-image snapshot image} of the VM. + # Typically, the same value as +name+. + # @param [Integer] cpu_cores The number of CPU cores to dedicate for the VM. Must be 3, 4, 6, 8, 12, or 24. + # @param [Integer] vcpu_count The number of vCPUs for the VM. Must equal the number of CPUs, when CPU is less + # than or equal to 3. Otherwise, must equal half of or exactly the number of CPUs specified. + # @param [Models::ISO, String] iso_image An ISO to attach to the VM on deployment. + # @param [Models::Image, String] attached_disk An additional storage disk to attach to the VM on deployment. + # @param [Boolean] vnc_console By default, +true+. Enables or disables VNC for the VM configuration. You can + # override on deployment of specific VMs. + # @param [String] system_serial Assign an owned macOS system serial number to the VM configuration. + # @param [Boolean] io_boost By default, +false+ for VM configurations created before Orka 1.5. Default value for + # VM configurations created with Orka 1.5 or later depends on the cluster default. Enables or disables IO + # performance improvements for the VM configuration. + # @param [Boolean] gpu_passthrough Enables or disables GPU passthrough for the VM. When enabled, +vnc_console+ is + # automatically disabled. GPU passthrough is an experimental feature. GPU passthrough must first be enabled in + # your cluster. + # @return [Models::VMConfiguration] The lazily-loaded VM configuration. + def create_vm_configuraton(name, + base_image:, snapshot_image:, cpu_cores:, vcpu_count:, + iso_image: nil, attached_disk: nil, vnc_console: nil, + system_serial: nil, io_boost: nil, gpu_passthrough: nil) + body = { + orka_vm_name: name, + orka_base_image: base_image.is_a?(Models::Image) ? base_image.name : base_image, + orka_image: snapshot_image.is_a?(Models::Image) ? snapshot_image.name : snapshot_image, + orka_cpu_core: cpu_cores, + vcpu_count: vcpu_count, + iso_image: iso_image.is_a?(Models::ISO) ? iso_image.name : iso_image, + attached_disk: attached_disk.is_a?(Models::Image) ? attached_disk.name : attached_disk, + vnc_console: vnc_console, + system_serial: system_serial, + io_boost: io_boost, + gpu_passthrough: gpu_passthrough, + }.compact + @conn.post("resources/vm/create", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + vm_configuration(name) + end + + # @!endgroup + # @!group Nodes + + # Retrieve a list of the nodes in your Orka environment. Orka returns a list of nodes with IP and resource + # information. + # + # If you set the admin parameter to true, this method requires the client to be configured with both a + # token and a license key. Otherwise, it only requires a token. + # + # @macro lazy_enumerator + # + # @param [Boolean] admin Set to true to allow nodes dedicated to other users to be queried. + # @return [Models::Enumerator] The enumerator of the node list. + def nodes(admin: false) + Models::Enumerator.new do + url = "resources/node/list" + url += "/all" if admin + nodes = @conn.get(url) do |r| + auth_type = [:token] + auth_type << :license if admin + r.options.context = { + orka_auth_type: auth_type, + } + end.body["nodes"] + nodes.map { |hash| Models::Node.from_hash(hash, conn: @conn, admin: admin) } + end + end + + # Fetches information on a particular node. + # + # If you set the admin parameter to true, this method requires the client to be configured with both a + # token and a license key. Otherwise, it only requires a token. + # + # @macro lazy_object + # + # @param [String] name The name of the node to fetch. + # @param [Boolean] admin Set to true to allow nodes dedicated with other users to be queried. + # @return [Models::VMResource] The lazily-loaded node object. + def node(name, admin: false) + Models::Node.lazy_prepare(name: name, conn: @conn, admin: admin) + end + + # @!endgroup + # @!group Images + + # Retrieve a list of the base images and empty disks in your Orka environment. + # + # @macro auth_token + # + # @macro lazy_enumerator + # + # @return [Models::Enumerator] The enumerator of the image list. + def images + Models::Enumerator.new do + images = @conn.get("resources/image/list") do |r| + r.options.context = { + orka_auth_type: :token, + } + end.body["image_attributes"] + images.map { |hash| Models::Image.from_hash(hash, conn: @conn) } + end + end + + # Fetches information on a particular image. + # + # @macro auth_token + # + # @macro lazy_object + # + # @param [String] name The name of the image to fetch. + # @return [Models::Image] The lazily-loaded image. + def image(name) + Models::Image.lazy_prepare(name: name, conn: @conn) + end + + # List the base images available in the Orka remote repo. + # + # To use one of the images from the remote repo, you can {Models::RemoteImage#pull pull} it into the local Orka + # storage. + # + # @macro auth_token + # + # @macro lazy_enumerator + # + # @return [Models::Enumerator] The enumerator of the remote image list. + def remote_images + Models::Enumerator.new do + images = @conn.get("resources/image/list-remote") do |r| + r.options.context = { + orka_auth_type: :token, + } + end.body["images"] + images.map { |name| Models::RemoteImage.new(name, conn: @conn) } + end + end + + # Returns an object representing a remote image of a specified name. + # + # Note that this method does not perform any network requests and does not verify if the name supplied actually + # exists in the Orka remote repo. + # + # @param [String] name The name of the remote image. + # @return [Models::RemoteImage] The remote image object. + def remote_image(name) + Models::RemoteImage.new(name, conn: @conn) + end + + # Generate an empty base image. You can use it to create VM configurations that will use an ISO or you can attach + # it to a deployed VM to extend its storage. + # + # @macro auth_token + # + # @param [String] name The name of this new image. + # @param [String] size The size of this new image (in K, M, G, or T), for example +"10G"+. + # @return [Models::Image] The new lazily-loaded image. + def generate_empty_image(name, size:) + body = { + file_name: name, + file_size: size, + }.compact + @conn.post("resources/image/generate", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + image(name) + end + + # Upload an image to the Orka environment. + # + # @macro auth_token + # + # @param [String, IO] file The string file path or an open IO object to the image to upload. + # @param [String] name The name to give to this image. Defaults to the local filename. + # @return [Models::Image] The new lazily-loaded image. + def upload_image(file, name: nil) + file_part = Faraday::Multipart::FilePart.new( + file, + "application/x-iso9660-image", + name, + ) + body = { image: file_part } + @conn.post("resources/image/upload", body, "Content-Type" => "multipart/form-data") do |r| + r.options.context = { + orka_auth_type: :token, + } + end + image(file_part.original_filename) + end + + # @!endgroup + # @!group Images + + # Retrieve a list of the ISOs available in the local Orka storage. + # + # @macro auth_token + # + # @macro lazy_enumerator + # + # @return [Models::Enumerator] The enumerator of the ISO list. + def isos + Models::Enumerator.new do + isos = @conn.get("resources/iso/list") do |r| + r.options.context = { + orka_auth_type: :token, + } + end.body["iso_attributes"] + isos.map { |hash| Models::ISO.from_hash(hash, conn: @conn) } + end + end + + # Fetches information on a particular ISO in local Orka storage. + # + # @macro auth_token + # + # @macro lazy_object + # + # @param [String] name The name of the ISO to fetch. + # @return [Models::ISO] The lazily-loaded ISO. + def iso(name) + Models::ISO.lazy_prepare(name: name, conn: @conn) + end + + # List the ISOs available in the Orka remote repo. + # + # To use one of the ISOs from the remote repo, you can {Models::RemoteISO#pull pull} it into the local Orka + # storage. + # + # @macro auth_token + # + # @macro lazy_enumerator + # + # @return [Models::Enumerator] The enumerator of the remote ISO list. + def remote_isos + Models::Enumerator.new do + isos = @conn.get("resources/iso/list-remote") do |r| + r.options.context = { + orka_auth_type: :token, + } + end.body["isos"] + isos.map { |name| Models::RemoteISO.new(name, conn: @conn) } + end + end + + # Returns an object representing a remote ISO of a specified name. + # + # Note that this method does not perform any network requests and does not verify if the name supplied actually + # exists in the Orka remote repo. + # + # @param [String] name The name of the remote ISO. + # @return [Models::RemoteISO] The remote ISO object. + def remote_iso(name) + Models::RemoteISO.new(name, conn: @conn) + end + + # Upload an ISO to the Orka environment. + # + # @macro auth_token + # + # @param [String, IO] file The string file path or an open IO object to the ISO to upload. + # @param [String] name The name to give to this ISO. Defaults to the local filename. + # @return [Models::ISO] The new lazily-loaded ISO. + def upload_iso(file, name: nil) + file_part = Faraday::Multipart::FilePart.new( + file, + "application/x-iso9660-image", + name, + ) + body = { iso: file_part } + @conn.post("resources/iso/upload", body, "Content-Type" => "multipart/form-data") do |r| + r.options.context = { + orka_auth_type: :token, + } + end + iso(file_part.original_filename) + end + + # @!endgroup + # @!group Kube-Accounts + + # Retrieve a list of kube-accounts associated with an Orka user. + # + # @macro auth_token_and_license + # + # @macro lazy_enumerator + # + # @param [Models::User, String] user The user, which can be specified by the user object or their email address, + # for which we are returning the associated kube-accounts of. Defaults to the user associated with the client's + # token. + # @return [Models::Enumerator] The enumerator of the kube-account list. + def kube_accounts(user: nil) + Models::Enumerator.new do + accounts = @conn.get("resources/kube-account") do |r| + email = user_email(user) + r.body = { + email: email, + }.compact + + r.options.context = { + orka_auth_type: [:token, :license], + } + end.body["serviceAccounts"] + accounts.map { |name| Models::KubeAccount.new(name, email: email, conn: @conn) } + end + end + + # Returns an object representing a kube-account of a particular user. + # + # Note that this method does not perform any network requests and does not verify if the name supplied actually + # exists in the Orka environment. + # + # @param [String] name The name of the kube-account. + # @param [Models::User, String] user The user, which can be specified by the user object or their email address, + # of which the kube-account is associated with. Defaults to the user associated with the client's token. + # @return [Models::KubeAccount] The kube-account object. + def kube_account(name, user: nil) + Models::KubeAccount.new(name, email: user_email(user), conn: @conn) + end + + # Create a kube-account. + # + # @macro auth_token_and_license + # + # @param [String] name The name of the kube-account. + # @param [Models::User, String] user The user, which can be specified by the user object or their email address, + # of which the kube-account will be associated with. Defaults to the user associated with the client's token. + # @return [Models::KubeAccount] The created kube-account. + def create_kube_account(name, user: nil) + email = user_email(user) + body = { + name: name, + email: email, + }.compact + kubeconfig = @conn.post("resources/kube-account", body) do |r| + r.options.context = { + orka_auth_type: [:token, :license], + } + end.body["kubeconfig"] + Models::KubeAccount.new(name, email: email, kubeconfig: kubeconfig, conn: @conn) + end + + # Delete all kube-accounts associated with a user. + # + # @macro auth_token_and_license + # + # @param [Models::User, String] user The user, which can be specified by the user object or their email address, + # which will have their associated kube-account deleted. Defaults to the user associated with the client's + # token. + # @return [void] + def delete_all_kube_accounts(user: nil) + @conn.delete("resources/kube-account") do |r| + email = user_email(user) + r.body = { + email: email, + }.compact + + r.options.context = { + orka_auth_type: [:token, :license], + } + end + end + + # @!endgroup + # @!group Logs + + # Retrieve a log of all CLI commands and API requests executed against your Orka environment. + # + # @macro auth_license + # + # @macro lazy_enumerator + # + # @param [Integer] limit Limit the amount of results returned to this amount. + # @return [Models::Enumerator] The enumerator of the log entries list. + def logs(limit: nil) + Models::Enumerator.new do + logs = @conn.post("logs/query") do |r| + r.params[:limit] = limit unless limit.nil? + + r.options.context = { + orka_auth_type: :license, + } + end.body["logs"] + logs.map { |hash| Models::LogEntry.new(hash) } + end + end + + # Delete all logs for your Orka environment. + # + # @macro auth_token_and_license + # + # @return [void] + def delete_logs + @conn.delete("logs") do |r| + r.options.context = { + orka_auth_type: [:license, :token], + } + end + end + + # @!endgroup + # @!group Environment Checks + + # Retrieve information about the token associated with the client. The request returns information about the + # associated email address, the authentication status of the token, and if the token is revoked. + # + # @macro auth_token + # + # @return [Models::TokenInfo] Information about the token. + def token_info + body = @conn.get("token") do |r| + r.options.context = { + orka_auth_type: :token, + } + end.body + Models::TokenInfo.new(body, conn: @conn) + end + + # Retrieve the current API version of your Orka environment. + # + # @macro auth_none + # + # @return [String] The remote API version. + def remote_api_version + @conn.get("health-check").body["api_version"] + end + + # Retrieve the current password requirements for creating an Orka user. + # + # @macro auth_none + # + # @return [Models::PasswordRequirements] The password requirements. + def password_requirements + Models::PasswordRequirements.new(@conn.get("validation-requirements").body) + end + + # Check if a license key is authorized or not. + # + # @macro auth_none + # + # @param [String] license_key The license key to check. Defaults to the one associated with the client. + # @return [Boolean] True if the license key is valid. + def license_key_valid?(license_key = @license_key) + raise ArgumentError, "License key is required." if license_key.nil? + + @conn.get("validate-license-key") do |r| + r.body = { + licenseKey: license_key, + } + end + true + rescue Faraday::UnauthorizedError + false + end + + # Retrieve the default base image for the Orka environment. + # + # @macro auth_none + # + # @return [Models::Image] The lazily-loaded default base image object. + def default_base_image + Image.lazy_prepare(name: @conn.get("default-base-image").body["default_base_image"], conn: @conn) + end + + # @!endgroup + + alias inspect to_s + + private + + def user_email(user) + if user.is_a?(Models::User) + user.email + else + user + end + end + end +end diff --git a/lib/orka_api_client/connection.rb b/lib/orka_api_client/connection.rb new file mode 100644 index 0000000..1686c99 --- /dev/null +++ b/lib/orka_api_client/connection.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "faraday" +require "faraday/multipart" +require_relative "auth_middleware" + +module OrkaAPI + # @api private + class Connection < ::Faraday::Connection + # @param [String] base_url + # @param [String] token + # @param [String] license_key + def initialize(base_url, token: nil, license_key: nil) + super( + url: base_url, + headers: { + "User-Agent" => "HomebrewOrkaClient/#{Client::VERSION}", + } + ) do |f| + f.request :orka_auth, token: token, license_key: license_key + f.request :json + f.request :multipart + f.response :json + f.response :raise_error # TODO: wrap this ourselves + end + end + + alias inspect to_s + end +end diff --git a/lib/orka_api_client/errors.rb b/lib/orka_api_client/errors.rb new file mode 100644 index 0000000..f5b8aa7 --- /dev/null +++ b/lib/orka_api_client/errors.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module OrkaAPI + # Base error class. + class Error < ::StandardError; end + + # This error is thrown if an endpoint requests an auth mechanism which we do not have credentials for. + class AuthConfigurationError < Error; end + + # This error is thrown if a specific resource is requested but it was not found in the Orka backend. + class ResourceNotFoundError < Error; end + + # This error is thrown if the client receives data from the server it does not recognise. This is typically + # indicative of a bug or a feature not yet implemented. + class UnrecognisedStateError < Error; end +end diff --git a/lib/orka_api_client/models/attr_predicate.rb b/lib/orka_api_client/models/attr_predicate.rb new file mode 100644 index 0000000..3462b69 --- /dev/null +++ b/lib/orka_api_client/models/attr_predicate.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module OrkaAPI + module Models + # @private + module AttrPredicate + private + + # @!parse + # # @!macro [attach] attr_predicate + # # @!attribute [r] $1? + # def self.attr_predicate(*); end + def attr_predicate(*attrs) + attrs.each do |attr| + define_method "#{attr}?" do + instance_variable_get("@#{attr}") + end + end + end + end + end +end diff --git a/lib/orka_api_client/models/disk.rb b/lib/orka_api_client/models/disk.rb new file mode 100644 index 0000000..612fb7d --- /dev/null +++ b/lib/orka_api_client/models/disk.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module OrkaAPI + module Models + # Information on a disk attached to a VM. + class Disk + # @return [String] + attr_reader :type + + # @return [String] + attr_reader :device + + # @return [String] + attr_reader :target + + # @return [String] + attr_reader :source + + # @api private + # @param [String] type + # @param [String] device + # @param [String] target + # @param [String] source + def initialize(type:, device:, target:, source:) + @type = type + @device = device + @target = target + @source = source + end + end + end +end diff --git a/lib/orka_api_client/models/enumerator.rb b/lib/orka_api_client/models/enumerator.rb new file mode 100644 index 0000000..40c67cb --- /dev/null +++ b/lib/orka_api_client/models/enumerator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module OrkaAPI + module Models + # Enumerator subclass for networked operations. + class Enumerator < ::Enumerator + # @api private + def initialize + super do |yielder| + yield.each do |item| + yielder << item + end + end + end + + # Forces this lazily-loaded enumerator to be fully loaded, peforming any necessary network operations. + # + # @return [self] + def eager + begin + peek + rescue StopIteration + # We're fine if the enuemrator is empty. + end + self + end + end + end +end diff --git a/lib/orka_api_client/models/image.rb b/lib/orka_api_client/models/image.rb new file mode 100644 index 0000000..b7eb39f --- /dev/null +++ b/lib/orka_api_client/models/image.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require_relative "lazy_model" + +module OrkaAPI + module Models + # A disk image that represents a VM's storage and its contents, including the OS and any installed software. + class Image < LazyModel + # @return [String] The name of this image. + attr_reader :name + + # @return [String] The size of this image. Orka lists generated empty storage disks with a fixed size of ~192k. + # When attached to a VM and formatted, the disk will appear with its correct size in the OS. + lazy_attr :size + + # @return [DateTime] The time this image was last modified. + lazy_attr :modification_time + + # @return [DateTime] The time this image was first created. + lazy_attr :creation_time + + # @return [String] + lazy_attr :owner + + # @api private + # @param [String] name + # @param [Connection] conn + # @return [Image] + def self.lazy_prepare(name:, conn:) + new(conn: conn, name: name) + end + + # @api private + # @param [Hash] hash + # @param [Connection] conn + # @return [Image] + def self.from_hash(hash, conn:) + new(conn: conn, hash: hash) + end + + # @private + # @param [Connection] conn + # @param [String] name + # @param [Hash] hash + def initialize(conn:, name: nil, hash: nil) + super(!hash.nil?) + @conn = conn + @name = name + deserialize(hash) if hash + end + + # Rename this image. + # + # @macro auth_token + # + # @note After you rename a base image, you can no longer deploy any VM configurations that are based on the + # image of the old name. + # + # @param [String] new_name The new name for this image. + # @return [void] + def rename(new_name) + body = { + image: @name, + new_name: new_name, + }.compact + @conn.post("resources/image/rename", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + @name = new_name + end + + # Copy this image to a new one. + # + # @macro auth_token + # + # @param [String] new_name The name for the copy of this image. + # @return [Image] The lazily-loaded image copy. + def copy(new_name) + body = { + image: @name, + new_name: new_name, + }.compact + @conn.post("resources/image/copy", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + Image.lazy_prepare(new_name, conn: @conn) + end + + # Delete this image from the local Orka storage. + # + # @macro auth_token + # + # @note Make sure that the image is not in use. + # + # @return [void] + def delete + body = { + image: @name, + }.compact + @conn.post("resources/image/delete", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Download this image from Orka cluster storage to your local filesystem. + # + # @macro auth_token + # + # @param [String, Pathname, IO] to An open IO, or a String/Pathname file path to the file or directory where + # you want the image to be written. + # @return [void] + def download(to:) + io_input = to.is_a?(::IO) + file = if io_input + to + else + to = File.join(to, @name) if File.directory?(to) + File.open(to, "wb:ASCII-8BIT") + end + @conn.get("resources/image/download/#{@name}") do |r| + r.options.context = { + orka_auth_type: :token, + } + + r.options.on_data = proc do |chunk, _| + file.write(chunk) + end + end + ensure + file.close unless io_input + end + + # Request the MD5 file checksum for this image in Orka cluster storage. The checksum can be used to verify file + # integrity for a downloaded or uploaded image. + # + # @macro auth_token + # + # @return [String, nil] The MD5 checksum of the image, or nil if the calculation is in progress and has not + # completed. + def checksum + @conn.get("resources/image/checksum/#{@name}") do |r| + r.options.context = { + orka_auth_type: :token, + } + end.body&.dig("checksum") + end + + private + + def lazy_initialize + response = @conn.get("resources/image/list") do |r| + r.options.context = { + orka_auth_type: :token, + } + end + image = response.body["image_attributes"].find { |hash| hash["image"] == @name } + + raise ResourceNotFoundError, "No image found matching \"#{@name}\"." if image.nil? + + deserialize(image) + super + end + + def deserialize(hash) + @name = hash["image"] + @size = hash["image_size"] + @modification_time = DateTime.iso8601(hash["modified"]) + @creation_time = DateTime.iso8601(hash["date_added"]) + @owner = hash["owner"] + end + end + end +end diff --git a/lib/orka_api_client/models/iso.rb b/lib/orka_api_client/models/iso.rb new file mode 100644 index 0000000..f76c523 --- /dev/null +++ b/lib/orka_api_client/models/iso.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require_relative "lazy_model" + +module OrkaAPI + module Models + # An +.iso+ disk image used exclusively for the installation of macOS on a virtual machine. You must attach the + # ISO to the VM during deployment. After the installation is complete and the VM has booted successfully, you + # need to restart the VM to detach the ISO. + class ISO < LazyModel + # @return [String] The name of this ISO. + attr_reader :name + + # @return [String] The size of this ISO. + lazy_attr :size + + # @return [DateTime] The time this image was last modified. + lazy_attr :modification_time + + # @api private + # @param [String] name + # @param [Connection] conn + # @return [ISO] + def self.lazy_prepare(name:, conn:) + new(conn: conn, name: name) + end + + # @api private + # @param [Hash] hash + # @param [Connection] conn + # @return [ISO] + def self.from_hash(hash, conn:) + new(conn: conn, hash: hash) + end + + # @private + # @param [Connection] conn + # @param [String] name + # @param [Hash] hash + def initialize(conn:, name: nil, hash: nil) + super(!hash.nil?) + @conn = conn + @name = name + deserialize(hash) if hash + end + + # Rename this ISO. + # + # @macro auth_token + # + # @note Make sure that the ISO is not in use. Any VMs that have the ISO of the old name attached will no longer + # be able to boot from it. + # + # @param [String] new_name The new name for this ISO. + # @return [void] + def rename(new_name) + body = { + iso: @name, + new_name: new_name, + }.compact + @conn.post("resources/iso/rename", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + @name = new_name + end + + # Copy this ISO to a new one. + # + # @macro auth_token + # + # @param [String] new_name The name for the copy of this ISO. + # @return [Image] The lazily-loaded ISO copy. + def copy(new_name) + body = { + iso: @name, + new_name: new_name, + }.compact + @conn.post("resources/iso/copy", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + ISO.lazy_prepare(new_name, conn: @conn) + end + + # Delete this ISO from the local Orka storage. + # + # @macro auth_token + # + # @note Make sure that the ISO is not in use. Any VMs that have the ISO attached will no longer be able to boot + # from it. + # + # @return [void] + def delete + body = { + iso: @name, + }.compact + @conn.post("resources/iso/delete", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + private + + def lazy_initialize + response = @conn.get("resources/iso/list") do |r| + r.options.context = { + orka_auth_type: :token, + } + end + iso = response.body["iso_attributes"].find { |hash| hash["iso"] == @name } + + raise ResourceNotFoundError, "No ISO found matching \"#{@name}\"." if iso.nil? + + deserialize(iso) + super + end + + def deserialize(hash) + @name = hash["iso"] + @size = hash["iso_size"] + @modification_time = DateTime.iso8601(hash["modified"]) + end + end + end +end diff --git a/lib/orka_api_client/models/kube_account.rb b/lib/orka_api_client/models/kube_account.rb new file mode 100644 index 0000000..514afe6 --- /dev/null +++ b/lib/orka_api_client/models/kube_account.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module OrkaAPI + module Models + # An account used for Kubernetes operations. + class KubeAccount + # @return [String] The name of this kube-account. + attr_reader :name + + # @api private + # @param [String] name + # @param [String] email + # @param [String] kubeconfig + # @param [Connection] conn + def initialize(name, conn:, email: nil, kubeconfig: nil) + @name = name + @email = email + @kubeconfig = kubeconfig + @conn = conn + end + + # Regenerate this kube-account. + # + # @macro auth_token_and_license + # + # @return [void] + def regenerate + body = { + name: name, + email: email, + }.compact + @kubeconfig = @conn.post("resources/kube-account/regenerate", body) do |r| + r.options.context = { + orka_auth_type: [:token, :license], + } + end.body["kubeconfig"] + end + + # Retrieve the +kubeconfig+ for this kube-account. + # + # This method is cached. Subsequent calls to this method will not invoke additional network requests. The + # methods {#regenerate} and {Client#create_kube_account} also fill this cache. + # + # @macro auth_token_and_license + # + # @return [void] + def kubeconfig + return @kubeconfig unless @kubeconfig.nil? + + @kubeconfig = @conn.get("resources/kube-account/download") do |r| + r.body = { + name: @name, + email: @email, + }.compact + + r.options.context = { + orka_auth_type: [:token, :license], + } + end.body["@kubeconfig"] + end + end + end +end diff --git a/lib/orka_api_client/models/lazy_model.rb b/lib/orka_api_client/models/lazy_model.rb new file mode 100644 index 0000000..66bb77f --- /dev/null +++ b/lib/orka_api_client/models/lazy_model.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module OrkaAPI + module Models + # The base class for lazily-loaded objects. + class LazyModel + # @!macro [attach] lazy_attr + # @!attribute [r] + def self.lazy_attr(*attrs) + attrs.each do |attr| + define_method attr do + ivar = "@#{attr.to_s.delete_suffix("?")}" + existing = instance_variable_get(ivar) + return existing unless existing.nil? + + eager + instance_variable_get(ivar) + end + end + end + private_class_method :lazy_attr + + # @private + # @param [Boolean] lazy_initialized + def initialize(lazy_initialized) + @lazy_initialized = lazy_initialized + end + private_class_method :new + + # Forces this lazily-loaded object to be fully loaded, peforming any necessary network operations. + # + # @return [self] + def eager + lazy_initialize unless @lazy_initialized + self + end + + # Re-fetches this object's data from the Orka API. This will raise an error if the object no longer exists. + # + # @return [void] + def refresh + lazy_initialize + end + + private + + def lazy_initialize + @lazy_initialized = true + end + end + end +end diff --git a/lib/orka_api_client/models/log_entry.rb b/lib/orka_api_client/models/log_entry.rb new file mode 100644 index 0000000..7a4ba78 --- /dev/null +++ b/lib/orka_api_client/models/log_entry.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module OrkaAPI + module Models + # A particular log event. + class LogEntry + # Information on the request element of a log event. + class Request + # @return [Hash] The body of the request. + attr_reader :body + + # @return [Hash{String => String}] The headers of the request. + attr_reader :headers + + # @return [String] The HTTP method used for the request. + attr_reader :method + + # @return [String] The request URL. + attr_reader :url + + # @api private + # @param [Hash] hash + def initialize(hash) + @body = hash["body"] + @headers = hash["headers"] + @method = hash["method"] + @url = hash["url"] + end + end + + # Information on the response element of a log event. + class Response + # @return [Hash] The body of the response. + attr_reader :body + + # @return [Hash{String => String}] The headers of the response. + attr_reader :headers + + # @return [Integer] The resultant HTTP status code of the response. + attr_reader :status_code + + # @api private + # @param [Hash] hash + def initialize(hash) + @body = hash["body"] + @headers = hash["headers"] + @status_code = hash["statusCode"] + end + end + + # Information on the user responsible for the log event. + class User + # @return [String] The user's email. + attr_reader :email + + # @return [String] The user's ID. + attr_reader :id + + # @api private + # @param [Hash] hash + def initialize(hash) + @email = hash["email"] + @id = hash["id"] + end + end + + # @return [DateTime] The time the log entry was created. + attr_reader :creation_time + + # @return [String] The ID of the log entry. + attr_reader :id + + # @return [Request] The HTTP request made. + attr_reader :request + + # @return [Response] The HTTP response returned. + attr_reader :response + + # @return [LogEntry::User, nil] The user which performed the action, if authenticated. + attr_reader :user + + # @api private + # @param [Hash] hash + def initialize(hash) + raise UnrecognisedStateError, "Unknown log version." if hash["logVersion"] != "1.0" + + @creation_time = DateTime.iso8601(hash["createdAt"]) + @id = hash["id"] + @request = Request.new(hash["request"]) + @response = Response.new(hash["response"]) + @user = if hash["user"].empty? + nil + else + User.new(hash["user"]) + end + end + end + end +end diff --git a/lib/orka_api_client/models/node.rb b/lib/orka_api_client/models/node.rb new file mode 100644 index 0000000..b218818 --- /dev/null +++ b/lib/orka_api_client/models/node.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require_relative "lazy_model" +require_relative "protocol_port_mapping" + +module OrkaAPI + module Models + # A physical or logical host that provides computational resources for your VMs. Usually, an Orka node is a + # genuine Apple physical host with a host OS on top. You have no direct access (via VNC, SSH, or Screen Sharing) + # to your nodes. + class Node < LazyModel + # @return [String] The name of this node. + attr_reader :name + + # @return [String] The host name of this node. + lazy_attr :host_name + + # @return [String] The IP address of this node. + lazy_attr :address + + # @return [String] The host IP address of this node. + lazy_attr :host_ip + + # @return [Integer] The number of free CPU cores on this node. + lazy_attr :available_cpu + + # @return [Integer] The total number of CPU cores on this node that are allocatable to VMs. + lazy_attr :allocatable_cpu + + # @return [String] The amount of free RAM on this node. + lazy_attr :available_memory + + # @return [Integer] The total number of CPU cores on this node. + lazy_attr :total_cpu + + # @return [String] The total amount of RAM on this node. + lazy_attr :total_memory + + # @return [String] The state of this node. + lazy_attr :state + + # @return [String, nil] The user group this node is dedicated to, if any. + lazy_attr :orka_group + + # @api private + # @param [String] name + # @param [Connection] conn + # @param [Boolean] admin + # @return [Node] + def self.lazy_prepare(name:, conn:, admin: false) + new(conn: conn, name: name, admin: admin) + end + + # @api private + # @param [Hash] hash + # @param [Connection] conn + # @param [Boolean] admin + # @return [Node] + def self.from_hash(hash, conn:, admin: false) + new(conn: conn, hash: hash, admin: admin) + end + + # @private + # @param [Connection] conn + # @param [String] name + # @param [Hash] hash + # @param [Boolean] admin + def initialize(conn:, name: nil, hash: nil, admin: false) + super(!hash.nil?) + @conn = conn + @name = name + @admin = admin + deserialize(hash) if hash + end + + # Get a detailed list of all reserved ports on this node. Orka lists them as port mappings between + # {ProtocolPortMapping#host_port host_port} and {ProtocolPortMapping#guest_port guest_port}. + # {ProtocolPortMapping#host_port host_port} indicates a port on the node, {ProtocolPortMapping#guest_port + # guest_port} indicates a port on a VM on this node. + # + # @macro auth_token + # + # @macro lazy_enumerator + # + # @return [Enumerator] The enumerator of the reserved ports list. + def reserved_ports + Enumerator.new do + all_ports = @conn.get("resources/ports") do |r| + r.options.context = { + orka_auth_type: :token, + } + end.body["reserved_ports"] + + node_ports = all_ports.select do |hash| + hash["orka_node_name"] == @name + end + + node_ports.map do |mapping| + ProtocolPortMapping.new( + host_port: mapping["host_port"], + guest_port: mapping["guest_port"], + protocol: mapping["protocol"], + ) + end + end + end + + # Tag this node as sandbox. This limits deployment management from the Orka CLI. You can perform only + # Kubernetes deployment management with +kubectl+, {https://helm.sh/docs/helm/#helm Helm}, and Tiller. + # + # @macro auth_token_and_license + # + # @return [void] + def enable_sandbox + body = { + orka_node_name: @name, + }.compact + @conn.post("resources/node/sandbox", body) do |r| + r.options.context = { + orka_auth_type: [:license, :token], + } + end + end + + # Remove the sandbox tag from this node. This re-enables deployment management with the Orka CLI. + # + # @macro auth_token_and_license + # + # @return [void] + def disable_sandbox + @conn.delete("resources/node/sandbox") do |r| + r.body = { + orka_node_name: @name, + }.compact + + r.options.context = { + orka_auth_type: [:license, :token], + } + end + end + + # Dedicate this node to a specified user group. Only users from this user group will be able to deploy to the + # node. + # + # @macro auth_token_and_license + # + # @note This is a BETA feature. + # + # @param [String, nil] group The user group to dedicate the node to. + # @return [void] + def dedicate_to_group(group) + body = [@name].compact + @conn.post("resources/node/groups/#{@group || "$ungrouped"}", body) do |r| + r.options.context = { + orka_auth_type: [:license, :token], + } + end + @orka_group = group + end + + # Make this node available to all users. + # + # @macro auth_token_and_license + # + # @note This is a BETA feature. + # + # @return [void] + def remove_group_dedication + dedicate_to_group(nil) + end + + private + + def lazy_initialize + # We don't use /resources/node/status/{name} as it only provides partial data. + url = "resources/node/list" + url += "/all" if @admin + response = @conn.get(url) do |r| + auth_type = [:token] + auth_type << :license if @admin + r.options.context = { + orka_auth_type: auth_type, + } + end + node = response.body["nodes"].find { |hash| hash["name"] == @name } + + raise ResourceNotFoundError, "No node found matching \"#{@name}\"." if node.nil? + + deserialize(node) + super + end + + def deserialize(hash) + @name = hash["name"] + @host_name = hash["host_name"] + @address = hash["address"] + @host_ip = hash["hostIP"] + @available_cpu = hash["available_cpu"] + @allocatable_cpu = hash["allocatable_cpu"] + @available_memory = hash["available_memory"] + @total_cpu = hash["total_cpu"] + @total_memory = hash["total_memory"] + @state = hash["state"] + @orka_group = hash["orka_group"] + end + end + end +end diff --git a/lib/orka_api_client/models/password_requirements.rb b/lib/orka_api_client/models/password_requirements.rb new file mode 100644 index 0000000..9a57653 --- /dev/null +++ b/lib/orka_api_client/models/password_requirements.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module OrkaAPI + module Models + # The requirements enforced for passwords when creating a user account. + class PasswordRequirements + # @return [Integer] The minimum length of a password. + attr_reader :length + + # @api private + # @param [Hash] hash + def initialize(hash) + @length = hash["password_length"] + end + end + end +end diff --git a/lib/orka_api_client/models/protocol_port_mapping.rb b/lib/orka_api_client/models/protocol_port_mapping.rb new file mode 100644 index 0000000..9e04ef1 --- /dev/null +++ b/lib/orka_api_client/models/protocol_port_mapping.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative "../port_mapping" + +module OrkaAPI + module Models + # Represents a port forwarding from a host node to a guest VM, with an additional field denoting the transport + # protocol. + class ProtocolPortMapping < PortMapping + # @return [String] The transport protocol, typically TCP. + attr_reader :protocol + + # @api private + # @param [Integer] host_port + # @param [Integer] guest_port + # @param [String] protocol + def initialize(host_port:, guest_port:, protocol:) + super(host_port: host_port, guest_port: guest_port) + @protocol = protocol + end + end + end +end diff --git a/lib/orka_api_client/models/remote_image.rb b/lib/orka_api_client/models/remote_image.rb new file mode 100644 index 0000000..cb0f5c2 --- /dev/null +++ b/lib/orka_api_client/models/remote_image.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module OrkaAPI + module Models + # Represents an image which exists in the Orka remote repo rather than local storage. + class RemoteImage + # @return [String] The name of this remote image. + attr_reader :name + + # @api private + # @param [String] name + # @param [Connection] conn + def initialize(name, conn:) + @name = name + @conn = conn + end + + # Pull this image from the remote repo. This is a long-running operation and might take a while. + # + # The operation copies the image to the local storage of your Orka environment. The base image will be + # available for use by all users of the environment. + # + # @macro auth_token + # + # @param [String] new_name The name for the local copy of this image. + # @return [Image] The lazily-loaded local image. + def pull(new_name) + body = { + image: @name, + new_name: new_name, + }.compact + @conn.post("resources/image/pull", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + Image.lazy_prepare(name: new_name, conn: @conn) + end + end + end +end diff --git a/lib/orka_api_client/models/remote_iso.rb b/lib/orka_api_client/models/remote_iso.rb new file mode 100644 index 0000000..bdc1a24 --- /dev/null +++ b/lib/orka_api_client/models/remote_iso.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module OrkaAPI + module Models + # Represents an ISO which exists in the Orka remote repo rather than local storage. + class RemoteISO + # @return [String] The name of this remote ISO. + attr_reader :name + + # @api private + # @param [String] name + # @param [Connection] conn + def initialize(name, conn:) + @name = name + @conn = conn + end + + # Pull an ISO from the remote repo. You can retain the ISO name or change it during the operation. This is a + # long-running operation and might take a while. + # + # The operation copies the ISO to the local storage of your Orka environment. The ISO will be available for use + # by all users of the environment. + # + # @macro auth_token + # + # @param [String] new_name The name for the local copy of this ISO. + # @return [ISO] The lazily-loaded local ISO. + def pull(new_name) + body = { + image: @name, + new_name: new_name, + }.compact + @conn.post("resources/iso/pull", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + ISO.lazy_prepare(name: new_name, conn: @conn) + end + end + end +end diff --git a/lib/orka_api_client/models/token_info.rb b/lib/orka_api_client/models/token_info.rb new file mode 100644 index 0000000..72e87b1 --- /dev/null +++ b/lib/orka_api_client/models/token_info.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative "attr_predicate" +require_relative "user" + +module OrkaAPI + module Models + # Provides information on the client's token. + class TokenInfo + extend AttrPredicate + + # @return [Boolean] True if the tokeb is valid for authentication. + attr_predicate :authenticated + + # @return [Boolean] True if the token has been revoked. + attr_predicate :token_revoked + + # @return [User] The user associated with the token. + attr_reader :user + + # @api private + # @param [Hash] hash + # @param [Connection] conn + def initialize(hash, conn:) + @authenticated = hash["authenticated"] + @token_revoked = hash["is_token_revoked"] + @user = Models::User.lazy_prepare(email: hash["email"], conn: conn) + end + end + end +end diff --git a/lib/orka_api_client/models/user.rb b/lib/orka_api_client/models/user.rb new file mode 100644 index 0000000..902b43d --- /dev/null +++ b/lib/orka_api_client/models/user.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require_relative "lazy_model" + +module OrkaAPI + module Models + # To work with Orka, you need to have a user with an assigned license. You will use this user and the respective + # credentials to authenticate against the Orka service. After being authenticated against the service, you can + # run Orka API calls. + class User < LazyModel + # @return [String] The email address for the user. + attr_reader :email + + # @return [String, nil] The group the user is in, if any. + lazy_attr :group + + # @api private + # @param [String] email + # @param [Connection] conn + # @return [User] + def self.lazy_prepare(email:, conn:) + new(conn: conn, email: email) + end + + # @api private + # @param [Connection] conn + # @param [String] email + # @param [String] group + def initialize(conn:, email:, group: nil) + super(!group.nil?) + @conn = conn + @email = email + @group = if group == "$ungrouped" + nil + else + group + end + end + public_class_method :new + + # Delete the user in the endpoint. The user must have no Orka resources associated with them (other than their + # authentication tokens). This operation invalidates all tokens associated with the user. + # + # @macro auth_token_and_license + # + # @return [void] + def delete + @conn.delete("users/#{@email}") do |r| + r.options.context = { + orka_auth_type: [:license, :token], + } + end + end + + # Reset the password for the user. This operation is intended for administrators. + # + # @macro auth_token_and_license + # + # @param [String] password The new password for the user. + # @return [void] + def reset_password(password) + body = { + email: email, + password: password, + }.compact + @conn.post("users/password", body) do |r| + r.options.context = { + orka_auth_type: [:license, :token], + } + end + end + + # Apply a group label to the user. + # + # @note This is a BETA feature. + # + # @macro auth_license + # + # @param [String] group The new group for the user. + # @return [void] + def change_group(group) + @conn.post("users/groups/#{group || "$ungrouped"}", [@email]) do |r| + r.options.context = { + orka_auth_type: :license, + } + end + @group = group + end + + # Remove a group label from the user. + # + # @note This is a BETA feature. + # + # @macro auth_license + # + # @return [void] + def remove_group + change_group(nil) + end + + private + + def lazy_initialize + groups = @conn.get("users") do |r| + r.options.context = { + orka_auth_type: :license, + } + end.body["user_groups"] + group = groups.find { |_, group_users| group_users.include?(@email) }&.first + + raise ResourceNotFoundError, "No user found matching \"#{@email}\"." if group.nil? + + @group = if group == "$ungrouped" + nil + else + group + end + super + end + end + end +end diff --git a/lib/orka_api_client/models/vm_configuration.rb b/lib/orka_api_client/models/vm_configuration.rb new file mode 100644 index 0000000..9535d6e --- /dev/null +++ b/lib/orka_api_client/models/vm_configuration.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require_relative "lazy_model" + +module OrkaAPI + module Models + # A template configuration (a container template) consisting of a + # {https://orkadocs.macstadium.com/docs/orka-glossary#base-image base image}, a + # {https://orkadocs.macstadium.com/docs/orka-glossary#snapshot-image snapshot image}, and the number of CPU cores + # to be used. To become a VM that you can run in the cloud, a VM configuration needs to be deployed to a {Node + # node}. + # + # You can deploy multiple VMs from a single VM configuration. Once created, you can no longer modify a VM + # configuration. + # + # Deleting a VM does not delete the VM configuration it was deployed from. + class VMConfiguration < LazyModel + # @return [String] The name of this VM configuration. + attr_reader :name + + # @return [User] The owner of this VM configuration, i.e. the user which deployed it. + lazy_attr :owner + + # @return [Image] The base image which newly deployed VMs of this configuration will boot from. + lazy_attr :base_image + + # @return [Integer] The number of CPU cores to allocate to deployed VMs of this configuration. + lazy_attr :cpu_cores + + # @return [ISO] The ISO to attach to deployed VMs of this configuration. + lazy_attr :iso_image + + # @return [Image] The storage disk image to attach to deployed VMs of this configuration. + lazy_attr :attached_disk + + # @return [Boolean] True if the VNC console should be enabled for deployed VMs of this configuration. + lazy_attr :vnc_console? + + # @return [Boolean] True if IO boost should be enabled for deployed VMs of this configuration. + lazy_attr :io_boost? + + # @return [Boolean] True if deployed VMs of this configuration should use a prior saved state (created via + # {VMInstance#save_state}) rather than a clean base image. + lazy_attr :use_saved_state? + + # @return [Boolean] True if GPU passthrough should be enabled for deployed VMs of this configuration. + lazy_attr :gpu_passthrough? + + # @api private + # @param [String] name + # @param [Connection] conn + # @return [VMConfiguration] + def self.lazy_prepare(name:, conn:) + new(conn: conn, name: name) + end + + # @api private + # @param [Hash] hash + # @param [Connection] conn + # @return [VMConfiguration] + def self.from_hash(hash, conn:) + new(conn: conn, hash: hash) + end + + # @private + # @param [Connection] conn + # @param [String] name + # @param [Hash] hash + def initialize(conn:, name: nil, hash: nil) + super(!hash.nil?) + @conn = conn + @name = name + deserialize(hash) if hash + end + + # Deploy the VM configuration to a node. If you don't specify a node, Orka chooses a node based on the + # available resources. + # + # @macro auth_token + # + # @param [Node, String] node The node on which to deploy the VM. The node must have sufficient CPU and memory + # to accommodate the VM. + # @param [Integer] replicas The scale at which to deploy the VM configuration. If not specified, defaults to + # +1+ (non-scaled). + # @param [Array] reserved_ports One or more port mappings that enable additional ports on your VM. + # @param [Boolean] iso_install Set to +true+ if you want to use an ISO. + # @param [Models::ISO, String] iso_image An ISO to attach to the VM during deployment. If already set in the + # respective VM configuration and not set here, Orka applies the setting from the VM configuration. You can + # also use this field to override any ISO specified in the VM configuration. + # @param [Boolean] attach_disk Set to +true+ if you want to attach additional storage during deployment. + # @param [Models::Image, String] attached_disk An additional storage disk to attach to the VM during + # deployment. If already set in the respective VM configuration and not set here, Orka applies the setting + # from the VM configuration. You can also use this field to override any storage specified in the VM + # configuration. + # @param [Boolean] vnc_console Enables or disables VNC for the VM. If not set in the VM configuration or here, + # defaults to +true+. If already set in the respective VM configuration and not set here, Orka applies the + # setting from the VM configuration. You can also use this field to override the VNC setting specified in the + # VM configuration. + # @param [Hash{String => String}] vm_metadata Inject custom metadata to the VM. If not set, only the built-in + # metadata is injected into the VM. + # @param [String] system_serial Assign an owned macOS system serial number to the VM. If already set in the + # respective VM configuration and not set here, Orka applies the setting from the VM configuration. + # @param [Boolean] gpu_passthrough Enables or disables GPU passthrough for the VM. If not set in the VM + # configuration or here, defaults to +false+. If already set in the respective VM configuration and not set + # here, Orka applies the setting from the VM configuration. You can also use this field to override the GPU + # passthrough setting specified in the VM configuration. When enabled, +vnc_console+ is automatically + # disabled. GPU passthrough is an experimental feature. GPU passthrough must first be enabled in your + # cluster. + # @return [void] + def deploy(node: nil, replicas: nil, reserved_ports: nil, iso_install: nil, + iso_image: nil, attach_disk: nil, attached_disk: nil, vnc_console: nil, + vm_metadata: nil, system_serial: nil, gpu_passthrough: nil) + VMResource.lazy_prepare(@name, conn: @conn).deploy( + node: node, + replicas: replicas, + reserved_ports: reserved_ports, + iso_install: iso_install, + iso_image: iso_image, + attach_disk: attach_disk, + attached_disk: attached_disk, + vnc_console: vnc_console, + vm_metadata: vm_metadata, + system_serial: system_serial, + gpu_passthrough: gpu_passthrough, + ) + end + + # Remove the VM configuration and all VM deployments of it. + # + # If the VM configuration and its deployments belong to the user associated with the client's token then the + # client only needs to be configured with a token. Otherwise, if you are removing a VM resource associated with + # another user, you need to configure the client with both a token and a license key. + # + # @return [void] + def purge + VMResource.lazy_prepare(@name, conn: @conn).purge + end + + # Delete the VM configuration state. Now when you deploy the VM configuration it will use the base image to + # boot the VM. + # + # To delete a VM state, it must not be used by any deployed VM. + # + # @macro auth_token + # + # @return [void] + def delete_saved_state + @conn.delete("resources/vm/configs/#{@name}/delete-state") do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + private + + def lazy_initialize + response = @conn.get("resources/vm/configs/#{@name}") do |r| + r.options.context = { + orka_auth_type: :token, + } + end + configs = response.body["configs"] + + raise ResourceNotFoundError, "No VM configuration found matching \"#{@name}\"." if configs.empty? + + deserialize(configs.first) + super + end + + def deserialize(hash) + @name = hash["orka_vm_name"] + @owner = User.lazy_prepare(email: hash["owner"], conn: @conn) + @base_image = Image.lazy_prepare(name: hash["orka_base_image"], conn: @conn) + @cpu_cores = hash["orka_cpu_core"] + @iso_image = if hash["iso_image"] == "None" + nil + else + ISO.lazy_prepare(name: hash["iso_image"], conn: @conn) + end + @attached_disk = if hash["attached_disk"] == "None" + nil + else + Image.lazy_prepare(name: hash["attached_disk"], conn: @conn) + end + @vnc_console = hash["vnc_console"] + @io_boost = hash["io_boost"] + @use_saved_state = hash["use_saved_state"] + @gpu_passthrough = hash["gpu_passthrough"] + end + end + end +end diff --git a/lib/orka_api_client/models/vm_instance.rb b/lib/orka_api_client/models/vm_instance.rb new file mode 100644 index 0000000..d81690b --- /dev/null +++ b/lib/orka_api_client/models/vm_instance.rb @@ -0,0 +1,468 @@ +# frozen_string_literal: true + +require_relative "attr_predicate" +require_relative "protocol_port_mapping" +require_relative "disk" + +module OrkaAPI + module Models + # A virtual machine deployed on a {Node node} from an existing {VMConfiguration VM configuration} or cloned from + # an existing virtual machine. In the case of macOS VMs, this is a full macOS VM inside of a + # {https://www.docker.com/resources/what-container Docker container}. + class VMInstance + extend AttrPredicate + + # @return [String] The ID of the VM. + attr_reader :id + + # @return [String] The name of the VM. + attr_reader :name + + # @return [Node] The node the VM is deployed on. + attr_reader :node + + # @return [User] The owner of the VM, i.e. the user which deployed it. + attr_reader :owner + + # @return [String] The state of the node the VM is deployed on. + attr_reader :node_status + + # @return [String] The IP of the VM. + attr_reader :ip + + # @return [Integer] The port used to connect to the VM via VNC. + attr_reader :vnc_port + + # @return [Integer] The port used to connect to the VM via macOS Screen Sharing. + attr_reader :screen_sharing_port + + # @return [Integer] The port used to connect to the VM via SSH. + attr_reader :ssh_port + + # @return [Integer] The number of CPU cores allocated to the VM. + attr_reader :cpu + + # @return [Integer] The number of vCPUs allocated to the VM. + attr_reader :vcpu + + # @return [String] The amount of RAM allocated to the VM. + attr_reader :ram + + # @return [Image] The base image the VM was deployed from. + attr_reader :base_image + + # @return [String] + attr_reader :image + + # @return [String] + attr_reader :configuration_template + + # @return [String] The status of the VM, at the time this class was initialized. + attr_reader :status + + # @return [Boolean] True if IO boost is enabled for this VM. + attr_predicate :io_boost + + # @return [Boolean] True if this VM is using a prior saved state rather than a clean base image. + attr_predicate :use_saved_state + + # @return [Array] The port mappings established for this VM. + attr_reader :reserved_ports + + # @return [DateTime] The time when this VM was deployed. + attr_reader :creation_time + + # @api private + # @param [Hash] hash + # @param [Connection] conn + # @param [Boolean] admin + def initialize(hash, conn:, admin: false) + @conn = conn + @admin = admin + deserialize(hash) + end + + # @!macro [new] vm_instance_state_note + # @note Calling this will not change the state of this object, and thus not change the return values of + # attributes like {#status}. You must fetch a new object instance from the client to refresh this data. + + # Remove the VM instance. + # + # If the VM instance belongs to the user associated with the client's token then the client only needs to be + # configured with a token. Otherwise, if you are removing a VM instance associated with another user, you need + # to configure the client with both a token and a license key. + # + # @macro vm_instance_state_note + # + # @return [void] + def delete + @conn.delete("resources/vm/delete") do |r| + r.body = { + orka_vm_name: @id, + }.compact + + auth_type = [:token] + auth_type << :license if @admin + r.options.context = { + orka_auth_type: auth_type, + } + end + end + + # Power ON the VM. + # + # @macro auth_token + # + # @macro vm_instance_state_note + # + # @return [void] + def start + body = { + orka_vm_name: @id, + }.compact + @conn.post("resources/vm/exec/start", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Power OFF the VM. + # + # @macro auth_token + # + # @macro vm_instance_state_note + # + # @return [void] + def stop + body = { + orka_vm_name: @id, + }.compact + @conn.post("resources/vm/exec/stop", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Suspend the VM. + # + # @macro auth_token + # + # @macro vm_instance_state_note + # + # @return [void] + def suspend + body = { + orka_vm_name: @id, + }.compact + @conn.post("resources/vm/exec/suspend", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Resume the VM. The VM must already be suspended. + # + # @macro auth_token + # + # @macro vm_instance_state_note + # + # @return [void] + def resume + body = { + orka_vm_name: @id, + }.compact + @conn.post("resources/vm/exec/resume", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Revert the VM to the latest state of its base image. This operation restarts the VM. + # + # @macro auth_token + # + # @macro vm_instance_state_note + # + # @return [void] + def revert + body = { + orka_vm_name: @id, + }.compact + @conn.post("resources/vm/exec/revert", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # List the disks attached to the VM. The VM must be non-scaled. + # + # @macro auth_token + # + # @macro lazy_enumerator + # + # @return [Enumerator] The enumerator of the disk list. + def disks + Enumerator.new do + drives = @conn.get("resources/vm/list-disks") do |r| + r.options.context = { + orka_auth_type: :token, + } + end.body["drives"] + drives.map do |hash| + Disk.new( + type: hash["type"], + device: hash["device"], + target: hash["target"], + source: hash["source"], + ) + end + end + end + + # Attach a disk to the VM. The VM must be non-scaled. + # + # You can attach any of the following disks: + # + # * Any disks created with {Client#generate_empty_image} + # * Any non-bootable images available in your Orka storage and listed by {Client#images} + # + # @macro auth_token + # + # @note Before you can use the attached disk, you need to restart the VM with a {#stop manual stop} of the VM, + # followed by a {#start manual start} VM. A software reboot from the OS will not trigger macOS to recognize + # the disk. + # + # @param [Image, String] image The disk to attach to the VM. + # @param [String] mount_point The mount point to attach the VM to. + # @return [void] + def attach_disk(image:, mount_point:) + body = { + orka_vm_name: @id, + image_name: image.is_a?(Image) ? image.name : image, + mount_point: mount_point, + }.compact + @conn.post("resources/vm/attach-disk", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Scale the VM to the specified number of replicas. + # + # When you are working with scaled VMs, the following limitations are in place: + # + # * Some operations are not available for scaled VMs (saving an image, committing changes to a base image, + # cloning a VM, migrating a VM). + # * If you specify a node during deployment, scaling is limited to the resources of that node and will not + # create new replicas on other nodes. + # * All replicas share the same ID. + # * When you scale down, you have no control over which replicas will be destroyed and which ones will be + # preserved. This might result in loss of data. + # * When you scale up, newly deployed replicas use the base image of the original VM config. Changes from any + # other running replicas are not applied to the new replicas. + # * Deleting a VM deletes all replicas. + # + # @macro auth_token + # + # @param [Integer] replicas The number of replicas to scale to. + # @return [void] + def scale(replicas) + body = { + orka_vm_name: @id, + replicas: replicas, + }.compact + @conn.patch("resources/vm/scale", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Un-scale a scaled VM. This removes any additional replicas of the VM and leaves only one running copy. + # + # When you scale down, you have no control over which replicas will be destroyed and which one will be + # preserved. This might result in loss of data. + # + # @macro auth_token + # + # @return [void] + def unscale + scale(1) + end + + # Migrate the VM from its {#node current node} to another destination node. The destination node should be + # different from the source node. + # + # This operation changes the IP of the migrated VM and might change its assigned ports. This operation removes + # the VM from the source node. + # + # @macro auth_token + # + # @macro vm_instance_state_note + # + # @param [Node, String] destination_node The node to migrate the VM to. + # @return [void] + def migrate(destination_node) + body = { + orka_vm_name: @id, + current_node_name: @node.name, + new_nodes: [destination_node.is_a?(Node) ? node.name : node], + }.compact + @conn.post("resources/vm/migrate", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Clone the VM from its {#node current node} to another destination node. The destination node should be + # different from the source node. + # + # This operation retains the VM on the source node. The clone receives a new ID and might receive a new IP and + # assigned ports, based on the destination node. + # + # @macro auth_token + # + # @param [Node, String] destination_node The node to migrate the VM to. + # @return [void] + def clone(destination_node) + body = { + orka_vm_name: @id, + current_node_name: @node.name, + new_nodes: [destination_node.is_a?(Node) ? node.name : node], + }.compact + @conn.post("resources/vm/clone", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Save the VM configuration state (disk and memory). + # + # If VM state is previously saved, it is overwritten. To overwrite the VM state, it must not be used by any + # deployed VM. + # + # @macro auth_token + # + # @note Saving VM state is restricted only to VMs that have GPU passthrough disabled. + # + # @return [void] + def save_state + body = { + orka_vm_name: @id, + }.compact + @conn.post("resources/vm/configs/save-state", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Apply the current state of the VM's image to the original base image in the Orka storage. Use this operation + # to modify an existing base image. All VM configs that reference this base image will be affected. + # + # The VM must be non-scaled. The base image to which you want to commit changes must be in use by only one VM. + # The base image to which you want to commit changes must not be in use by a VM configuration with saved VM + # state. + # + # @macro auth_token + # + # @return [void] + def commit_to_base_image + body = { + orka_vm_name: @id, + }.compact + @conn.post("resources/image/commit", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Save the current state of the VM's image to a new base image in the Orka storage. Use this operation to + # create a new base image. + # + # The VM must be non-scaled. The base image name that you specify must not be in use. + # + # @macro auth_token + # + # @param [String] image_name The name to give to the new base image. + # @return [Image] The lazily-loaded new base image. + def save_new_base_image(image_name) + body = { + orka_vm_name: @id, + new_name: image_name, + }.compact + @conn.post("resources/image/save", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + Image.lazy_prepare(image_name, conn: @conn) + end + + # Resize the current disk of the VM and save it as a new base image. This does not affect the original base + # image of the VM. + # + # @macro auth_token + # + # @param [String] username The username of the VM user. + # @param [String] password The password of the VM user. + # @param [String] image_name The new name for the resized image. + # @param [String] image_size The size of the new image (in k, M, G, or T), for example +"100G"+. + # @return [Image] The lazily-loaded new base image. + def resize_image(username:, password:, image_name:, image_size:) + body = { + orka_vm_name: @id, + vm_username: username, + vm_password: password, + new_image_size: image_size, + new_image_name: image_name, + }.compact + @conn.post("resources/image/resize", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + Image.lazy_prepare(image_name, conn: @conn) + end + + private + + def deserialize(hash) + @owner = User.lazy_prepare(email: hash["owner"], conn: @conn) + @name = hash["virtual_machine_name"] + @id = hash["virtual_machine_id"] + @node = Node.lazy_prepare(name: hash["node_location"], conn: @conn, admin: @admin) + @node_status = hash["node_status"] + @ip = hash["virtual_machine_ip"] + @vnc_port = hash["vnc_port"].to_i + @screen_sharing_port = hash["screen_sharing_port"].to_i + @ssh_port = hash["ssh_port"].to_i + @cpu = hash["cpu"] + @vcpu = hash["vcpu"] + @ram = hash["RAM"] + @base_image = Image.lazy_prepare(name: hash["base_image"], conn: @conn) + @image = hash["image"] # TODO: rename this? provide an object? + @configuration_template = hash["configuration_template"] + @io_boost = hash["io_boost"] + @use_saved_state = hash["use_saved_state"] + @reserved_ports = hash["reserved_ports"].map do |mapping| + ProtocolPortMapping.new( + host_port: mapping["host_port"], + guest_port: mapping["guest_port"], + protocol: mapping["protocol"], + ) + end + @creation_time = DateTime.iso8601(hash["creation_timestamp"]) + end + end + end +end diff --git a/lib/orka_api_client/models/vm_resource.rb b/lib/orka_api_client/models/vm_resource.rb new file mode 100644 index 0000000..d9b00b9 --- /dev/null +++ b/lib/orka_api_client/models/vm_resource.rb @@ -0,0 +1,355 @@ +# frozen_string_literal: true + +require_relative "lazy_model" +require_relative "vm_instance" + +module OrkaAPI + module Models + # A general representation of {VMConfiguration VM configurations} and the {VMInstance VMs} deployed from those + # configurations. + class VMResource < LazyModel + # @return [String] The name of this VM resource. + attr_reader :name + + # @return [Boolean] True if there are associated deployed VM instances. + lazy_attr :deployed? + + # @return [Array] The list of deployed VM instances. + lazy_attr :instances + + # @return [User, nil] The owner of the associated VM configuration. This is +nil+ if {#deployed?} is +true+. + lazy_attr :owner + + # @return [Integer, nil] The number of CPU cores to use, specified by the associated VM configuration. This is + # +nil+ if {#deployed?} is +true+. + lazy_attr :cpu + + # @return [Integer, nil] The number of vCPUs to use, specified by the associated VM configuration. This is + # +nil+ if {#deployed?} is +true+. + lazy_attr :vcpu + + # @return [Image, nil] The base image to use, specified by the associated VM configuration. This is +nil+ if + # {#deployed?} is +true+. + lazy_attr :base_image + + # @return [String, nil] + lazy_attr :image + + # @return [Boolean, nil] True if IO boost is enabled, specified by the associated VM configuration. This is + # +nil+ if {#deployed?} is +true+. + lazy_attr :io_boost? + + # @return [Boolean, nil] True if the saved state should be used rather than cleanly from the base image, + # specified by the associated VM configuration. This is +nil+ if {#deployed?} is +true+. + lazy_attr :use_saved_state? + + # @return [Boolean, nil] True if GPU passthrough is enabled, specified by the associated VM configuration. This + # is +nil+ if {#deployed?} is +true+. + lazy_attr :gpu_passthrough? + + # @return [String, nil] + lazy_attr :configuration_template + + # @api private + # @param [String] name + # @param [Connection] conn + # @param [Boolean] admin + # @return [VMResource] + def self.lazy_prepare(name:, conn:, admin: false) + new(conn: conn, name: name, admin: admin) + end + + # @api private + # @param [Hash] hash + # @param [Connection] conn + # @param [Boolean] admin + # @return [VMResource] + def self.from_hash(hash, conn:, admin: false) + new(conn: conn, hash: hash, admin: admin) + end + + # @private + # @param [Connection] conn + # @param [String] name + # @param [Hash] hash + # @param [Boolean] admin + def initialize(conn:, name: nil, hash: nil, admin: false) + super(!hash.nil?) + @conn = conn + @name = name + @admin = admin + deserialize(hash) if hash + end + + # @!macro [new] vm_resource_state_note + # @note Calling this will not change the state of this object, and thus not change the return values of + # {#deployed?} and {#instances}, if the object already been loaded. You must fetch a new object instance or + # call {#refresh} to refresh this data. + + # Deploy an existing VM configuration to a node. If you don't specify a node, Orka chooses a node based on the + # available resources. + # + # @macro auth_token + # + # @macro vm_resource_state_note + # + # @param [Node, String] node The node on which to deploy the VM. The node must have sufficient CPU and memory + # to accommodate the VM. + # @param [Integer] replicas The scale at which to deploy the VM configuration. If not specified, defaults to + # +1+ (non-scaled). + # @param [Array] reserved_ports One or more port mappings that enable additional ports on your VM. + # @param [Boolean] iso_install Set to +true+ if you want to use an ISO. + # @param [Models::ISO, String] iso_image An ISO to attach to the VM during deployment. If already set in the + # respective VM configuration and not set here, Orka applies the setting from the VM configuration. You can + # also use this field to override any ISO specified in the VM configuration. + # @param [Boolean] attach_disk Set to +true+ if you want to attach additional storage during deployment. + # @param [Models::Image, String] attached_disk An additional storage disk to attach to the VM during + # deployment. If already set in the respective VM configuration and not set here, Orka applies the setting + # from the VM configuration. You can also use this field to override any storage specified in the VM + # configuration. + # @param [Boolean] vnc_console Enables or disables VNC for the VM. If not set in the VM configuration or here, + # defaults to +true+. If already set in the respective VM configuration and not set here, Orka applies the + # setting from the VM configuration. You can also use this field to override the VNC setting specified in the + # VM configuration. + # @param [Hash{String => String}] vm_metadata Inject custom metadata to the VM. If not set, only the built-in + # metadata is injected into the VM. + # @param [String] system_serial Assign an owned macOS system serial number to the VM. If already set in the + # respective VM configuration and not set here, Orka applies the setting from the VM configuration. + # @param [Boolean] gpu_passthrough Enables or disables GPU passthrough for the VM. If not set in the VM + # configuration or here, defaults to +false+. If already set in the respective VM configuration and not set + # here, Orka applies the setting from the VM configuration. You can also use this field to override the GPU + # passthrough setting specified in the VM configuration. When enabled, +vnc_console+ is automatically + # disabled. GPU passthrough is an experimental feature. GPU passthrough must first be enabled in your + # cluster. + # @return [void] + def deploy(node: nil, replicas: nil, reserved_ports: nil, iso_install: nil, + iso_image: nil, attach_disk: nil, attached_disk: nil, vnc_console: nil, + vm_metadata: nil, system_serial: nil, gpu_passthrough: nil) + vm_metadata = { items: vm_metadata.map { |k, v| { key: k, value: v } } } unless vm_metadata.nil? + body = { + orka_vm_name: @name, + orka_node_name: node.is_a?(Node) ? node.name : node, + replicas: replicas, + reserved_ports: reserved_ports&.map { |mapping| "#{mapping.host_port}:#{mapping.guest_port}" }, + iso_install: iso_install, + iso_image: iso_image.is_a?(ISO) ? iso_image.name : iso_image, + attach_disk: attach_disk, + attached_disk: attached_disk.is_a?(Image) ? attached_disk.name : attached_disk, + vnc_console: vnc_console, + vm_metadata: vm_metadata, + system_serial: system_serial, + gpu_passthrough: gpu_passthrough, + }.compact + @conn.post("resources/vm/deploy", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Removes all VM instances. + # + # If the VM instances belongs to the user associated with the client's token then the client only needs to be + # configured with a token. Otherwise, if you are removing VM instances associated with another user, you need + # to configure the client with both a token and a license key. + # + # @macro vm_resource_state_note + # + # @param [Node, String] node If specified, only remove VM deployments on that node. + # @return [void] + def delete_all_instances(node: nil) + @conn.delete("resources/vm/delete") do |r| + r.body = { + orka_vm_name: @name, + orka_node_name: node.is_a?(Node) ? node.name : node, + }.compact + + auth_type = [:token] + auth_type << :license if @admin + r.options.context = { + orka_auth_type: auth_type, + } + end + end + + # Remove all VM instances and the VM configuration. + # + # If the VM resource belongs to the user associated with the client's token then the client only needs to be + # configured with a token. Otherwise, if you are removing a VM resource associated with another user, you need + # to configure the client with both a token and a license key. + # + # @macro vm_resource_state_note + # + # @return [void] + def purge + @conn.delete("resources/vm/purge") do |r| + r.body = { + orka_vm_name: @name, + }.compact + + auth_type = [:token] + auth_type << :license if @admin + r.options.context = { + orka_auth_type: auth_type, + } + end + end + + # Power ON all VM instances on a particular node that are associated with this VM resource. + # + # @macro auth_token + # + # @macro vm_resource_state_note + # + # @param [Node, String] node All deployments of this VM located on this node will be started. + # @return [void] + def start_all_on_node(node) + raise ArgumentError, "Node cannot be nil." if node.nil? + + body = { + orka_vm_name: @name, + orka_node_name: node.is_a?(Node) ? node.name : node, + }.compact + @conn.post("resources/vm/exec/start", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Power OFF all VM instances on a particular node that are associated with this VM resource. + # + # @macro auth_token + # + # @macro vm_resource_state_note + # + # @param [Node, String] node All deployments of this VM located on this node will be stopped. + # @return [void] + def stop_all_on_node(node) + raise ArgumentError, "Node cannot be nil." if node.nil? + + body = { + orka_vm_name: @name, + orka_node_name: node.is_a?(Node) ? node.name : node, + }.compact + @conn.post("resources/vm/exec/stop", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Suspend all VM instances on a particular node that are associated with this VM resource. + # + # @macro auth_token + # + # @macro vm_resource_state_note + # + # @param [Node, String] node All deployments of this VM located on this node will be suspended. + # @return [void] + def suspend_all_on_node(node) + raise ArgumentError, "Node cannot be nil." if node.nil? + + body = { + orka_vm_name: @name, + orka_node_name: node.is_a?(Node) ? node.name : node, + }.compact + @conn.post("resources/vm/exec/suspend", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Resume all VM instances on a particular node that are associated with this VM resource. + # + # @macro auth_token + # + # @macro vm_resource_state_note + # + # @param [Node, String] node All deployments of this VM located on this node will be resumed. + # @return [void] + def resume_all_on_node(node) + raise ArgumentError, "Node cannot be nil." if node.nil? + + body = { + orka_vm_name: @name, + orka_node_name: node.is_a?(Node) ? node.name : node, + }.compact + @conn.post("resources/vm/exec/resume", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + # Revert all VM instances on a particular node that are associated with this VM resource to the latest state of + # its base image. This operation restarts the VMs. + # + # @macro auth_token + # + # @macro vm_resource_state_note + # + # @param [Node, String] node All deployments of this VM located on this node will be reverted. + # @return [void] + def revert_all_on_node(node) + raise ArgumentError, "Node cannot be nil." if node.nil? + + body = { + orka_vm_name: @name, + orka_node_name: node.is_a?(Node) ? node.name : node, + }.compact + @conn.post("resources/vm/exec/revert", body) do |r| + r.options.context = { + orka_auth_type: :token, + } + end + end + + private + + def lazy_initialize + response = @conn.get("resources/vm/status/#{@name}") do |r| + auth_type = [:token] + auth_type << :license if @admin + r.options.context = { + orka_auth_type: auth_type, + } + end + resources = response.body["virtual_machine_resources"] + + raise ResourceNotFoundError, "No VM resource found matching \"#{@name}\"." if resources.empty? + + deserialize(resources.first) + super + end + + def deserialize(hash) + @name = hash["virtual_machine_name"] + @deployed = case hash["vm_deployment_status"] + when "Deployed" + true + when "Not Deployed" + false + else + raise UnrecognisedStateError, "Unrecognised VM deployment status." + end + + if @deployed + @instances = hash["status"].map { |instance| VMInstance.new(instance, conn: @conn, admin: @admin) } + else + @instances = [] + @owner = User.lazy_prepare(email: hash["owner"], conn: @conn) + @cpu = hash["cpu"] + @vcpu = hash["vcpu"] + @base_image = Image.lazy_prepare(name: hash["base_image"], conn: @conn) + @image = hash["image"] # TODO: rename this? provide an object? + @io_boost = hash["io_boost"] + @use_saved_state = hash["use_saved_state"] + @gpu_passthrough = hash["gpu_passthrough"] + @configuration_template = hash["configuration_template"] + end + end + end + end +end diff --git a/lib/orka_api_client/port_mapping.rb b/lib/orka_api_client/port_mapping.rb new file mode 100644 index 0000000..64987dc --- /dev/null +++ b/lib/orka_api_client/port_mapping.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module OrkaAPI + # Represents a port forwarding from a host node to a guest VM. + class PortMapping + # @return [Integer] The port on the node side. + attr_reader :host_port + + # @return [Integer] The port on the VM side. + attr_reader :guest_port + + # @param [Integer] host_port The port on the node side. + # @param [Integer] guest_port The port on the VM side. + def initialize(host_port:, guest_port:) + @host_port = host_port + @guest_port = guest_port + end + end +end diff --git a/lib/orka_api_client/version.rb b/lib/orka_api_client/version.rb new file mode 100644 index 0000000..01df993 --- /dev/null +++ b/lib/orka_api_client/version.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module OrkaAPI + class Client + # The version of this gem. + VERSION = "0.1.0" + + # The Orka API version this gem supports. Support for other versions is not guaranteed, in particular older + # versions. + API_VERSION = "1.7.0" + end +end diff --git a/orka_api_client.gemspec b/orka_api_client.gemspec new file mode 100644 index 0000000..68fe39b --- /dev/null +++ b/orka_api_client.gemspec @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "lib/orka_api_client/version" + +Gem::Specification.new do |spec| + spec.name = "orka_api_client" + spec.version = OrkaAPI::Client::VERSION + spec.authors = ["Bo Anderson"] + spec.email = ["mail@boanderson.me"] + + spec.summary = "Ruby library for interfacing with the MacStadium Orka API." + spec.homepage = "https://github.com/Homebrew/orka_api_client" + spec.required_ruby_version = ">= 2.6.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/Homebrew/orka_api_client" + spec.metadata["bug_tracker_uri"] = "https://github.com/Homebrew/orka_api_client/issues" + spec.metadata["changelog_uri"] = "https://github.com/Homebrew/orka_api_client/releases/tag/#{spec.version}" + spec.metadata["rubygems_mfa_required"] = "true" + + spec.files = Dir.chdir(File.expand_path(__dir__)) do + (Dir.glob("*.{md,txt}") + Dir.glob("{exe,lib,rbi}/**/*")).reject { |f| File.directory?(f) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "faraday", "~> 2.0" + spec.add_dependency "faraday-multipart", "~> 1.0" +end diff --git a/spec/client_spec.rb b/spec/client_spec.rb new file mode 100644 index 0000000..2702f40 --- /dev/null +++ b/spec/client_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe OrkaAPI::Client do + # TODO +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..2a00a22 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "orka_api_client" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end