Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ tmp
/.idea
/.vscode
Makefile
.ruby-version
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ test/version_tmp
tmp
/.idea
/.vscode
coverage/
4 changes: 3 additions & 1 deletion .rspec
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
--color
--format documentation
--format documentation
--require spec_helper
--tag ~integration
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ gem 'activesupport', '~> 4.0'
gem 'rack', '~> 1.0'

group :test do
gem 'simplecov'
gem 'simplecov_json_formatter'
gem 'webmock'
end

Expand Down
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,13 @@ bash: build
test: build
docker compose run cli bundle exec rake

.PHONY: build bash test
integration: build
@echo "Running integration tests..."
@echo "Set DEPLOY_API_URL to point to your running deploy-api instance"
@echo "Set DEPLOY_API_TOKEN if authentication is required"
docker compose run \
-e DEPLOY_API_URL=${DEPLOY_API_URL} \
-e DEPLOY_API_TOKEN=${DEPLOY_API_TOKEN} \
cli bundle exec rspec --tag integration

.PHONY: build bash test integration
12 changes: 10 additions & 2 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
require 'rubocop/rake_task'

require 'aptible/tasks'
Aptible::Tasks.load_tasks
RSpec::Core::RakeTask.new(:spec) do |spec|
spec.pattern = 'spec/**/*_spec.rb'
spec.rspec_opts = '--exclude-pattern spec/integration/**/*_spec.rb'
end

RuboCop::RakeTask.new

task default: [:spec, :rubocop]
3 changes: 3 additions & 0 deletions lib/aptible/cli/agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
require_relative 'helpers/date_helpers'
require_relative 'helpers/s3_log_helpers'
require_relative 'helpers/maintenance'
require_relative 'helpers/ai_token'
require_relative 'helpers/aws_account'

require_relative 'subcommands/apps'
Expand All @@ -46,6 +47,7 @@
require_relative 'subcommands/metric_drain'
require_relative 'subcommands/maintenance'
require_relative 'subcommands/backup_retention_policy'
require_relative 'subcommands/ai_tokens'
require_relative 'subcommands/aws_accounts'

module Aptible
Expand Down Expand Up @@ -76,6 +78,7 @@ class Agent < Thor
include Subcommands::MetricDrain
include Subcommands::Maintenance
include Subcommands::BackupRetentionPolicy
include Subcommands::AiTokens
include Subcommands::AwsAccounts

# Forward return codes on failures.
Expand Down
90 changes: 90 additions & 0 deletions lib/aptible/cli/helpers/ai_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
module Aptible
module CLI
module Helpers
module AiToken
include Helpers::Token

def ensure_ai_token(account, id)
ai_tokens = account.ai_tokens.select { |t| t.id.to_s == id.to_s }

raise Thor::Error, "AI token #{id} not found or access denied" if ai_tokens.empty?

ai_tokens.first
rescue HyperResource::ClientError => e
if e.response.status == 404
raise Thor::Error, "AI token #{id} not found or access denied"
else
raise Thor::Error, "Failed to retrieve token: #{e.message}"
end
end

def create_ai_token(account, opts)
ai_token = account.create_ai_token!(opts)

# Log full HAL response in debug mode
if ENV['APTIBLE_DEBUG'] == 'DEBUG'
begin
CLI.logger.warn "POST create response: #{JSON.pretty_generate(ai_token.body)}"
rescue StandardError
CLI.logger.warn "POST create response: #{ai_token.body.inspect}"
end
end

Formatter.render(Renderer.current) do |root|
root.object do |node|
ResourceFormatter.inject_ai_token(node, ai_token, account)

# Include the token value on creation if present
token_value = ai_token.attributes['token']
node.value('token', token_value) if token_value
end
end

# Warn about token value and gateway URL if present
token_value = ai_token.attributes['token']
gateway_url = ai_token.attributes['gateway_url']
if token_value
CLI.logger.warn "\nSave the token value now - it will not be shown again!"
CLI.logger.warn "Use this token to authenticate requests to: #{gateway_url}" if gateway_url
end

ai_token
rescue HyperResource::ClientError, HyperResource::ServerError => e
# Log response body in debug mode
if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.respond_to?(:response) && e.response
begin
body = e.response.body
parsed_body = body.is_a?(String) ? JSON.parse(body) : body
CLI.logger.warn "POST create error response (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}"
rescue StandardError
CLI.logger.warn "POST create error response (#{e.response.status}): #{e.response.body.inspect}"
end
end

# Extract clean error message from response
error_message = if e.respond_to?(:body) && e.body.is_a?(Hash)
e.body['error'] || e.message
elsif e.respond_to?(:response) && e.response&.status
"Failed to create token: HTTP #{e.response.status}"
else
e.message
end
raise Thor::Error, error_message
rescue HyperResource::ResponseError => e
# Log response body in debug mode
if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response
begin
body = e.response.body
parsed_body = body.is_a?(String) ? JSON.parse(body) : body
CLI.logger.warn "POST create response error (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}"
rescue StandardError
CLI.logger.warn "POST create response error (#{e.response.status}): #{e.response.body.inspect}"
end
end

raise Thor::Error, "Failed to create token: HTTP #{e.response&.status || 'unknown error'}"
end
end
end
end
end
102 changes: 102 additions & 0 deletions lib/aptible/cli/resource_formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,108 @@ def inject_metric_drain(node, metric_drain, account)
attach_account(node, account)
end

def inject_ai_token(node, ai_token, account, include_display: false)
require 'base64'

node.value('id', ai_token.id)

# Decode the note from URL-safe base64 (encrypted at rest, decoded here for display)
encoded_note = ai_token.attributes['note'] rescue nil
note = if encoded_note
begin
Base64.urlsafe_decode64(encoded_note)
rescue ArgumentError
encoded_note # Fall back to raw value if decoding fails
end
end
node.value('note', note) if note

# Check blocked status (use attributes hash for HyperResource compatibility)
is_blocked = ai_token.attributes['blocked'] rescue false

# Display field is only used for list view (grouped_keyed_list)
if include_display
# Determine status: revoked if blocked, otherwise active
status = is_blocked ? 'REVOKED' : 'ACTIVE'

# Format: "ID ACTIVE note" or "ID REVOKED note"
# Pad ACTIVE with 2 extra spaces to align with REVOKED (7 chars + 1 space = 8)
status_padded = status == 'ACTIVE' ? 'ACTIVE ' : 'REVOKED '
display_note = note || ''
node.value('display', "#{ai_token.id} #{status_padded}#{display_note}")
end

node.value('created_at', ai_token.created_at)

# Optional fields - only include if present (use attributes hash for HyperResource compatibility)
updated_at = ai_token.attributes['updated_at'] rescue nil
node.value('updated_at', updated_at) if updated_at

last_used_at = ai_token.attributes['last_used_at'] rescue nil
node.value('last_used_at', last_used_at) if last_used_at

# Show status in detail view
node.value('status', is_blocked ? 'REVOKED' : 'ACTIVE')

# Include gateway URL if present
gateway_url = ai_token.attributes['gateway_url'] rescue nil
node.value('gateway_url', gateway_url) if gateway_url

# Include actor tracking info if present (encrypted at rest in LLM Gateway, decrypted by deploy-api)
# Show user (on whose behalf) details
created_by_user_id = ai_token.attributes['created_by_user_id'] rescue nil
created_by_actor_id = ai_token.attributes['created_by_actor_id'] rescue nil

node.value('created_by_user_id', created_by_user_id) if created_by_user_id

created_by_user_name = ai_token.attributes['created_by_user_name'] rescue nil
node.value('created_by_user_name', created_by_user_name) if created_by_user_name

created_by_user_email = ai_token.attributes['created_by_user_email'] rescue nil
node.value('created_by_user_email', created_by_user_email) if created_by_user_email

# Only show actor (who performed) details if different from user (impersonation case)
if created_by_actor_id && created_by_actor_id != created_by_user_id
node.value('created_by_actor_id', created_by_actor_id)

created_by_actor_name = ai_token.attributes['created_by_actor_name'] rescue nil
node.value('created_by_actor_name', created_by_actor_name) if created_by_actor_name

created_by_actor_email = ai_token.attributes['created_by_actor_email'] rescue nil
node.value('created_by_actor_email', created_by_actor_email) if created_by_actor_email
end

# Show revoked_by user details
revoked_by_user_id = ai_token.attributes['revoked_by_user_id'] rescue nil
revoked_by_actor_id = ai_token.attributes['revoked_by_actor_id'] rescue nil

if revoked_by_user_id
node.value('revoked_by_user_id', revoked_by_user_id)

revoked_by_user_name = ai_token.attributes['revoked_by_user_name'] rescue nil
node.value('revoked_by_user_name', revoked_by_user_name) if revoked_by_user_name

revoked_by_user_email = ai_token.attributes['revoked_by_user_email'] rescue nil
node.value('revoked_by_user_email', revoked_by_user_email) if revoked_by_user_email
end

# Only show revoked_by actor details if different from user (impersonation case)
if revoked_by_actor_id && revoked_by_actor_id != revoked_by_user_id
node.value('revoked_by_actor_id', revoked_by_actor_id)

revoked_by_actor_name = ai_token.attributes['revoked_by_actor_name'] rescue nil
node.value('revoked_by_actor_name', revoked_by_actor_name) if revoked_by_actor_name

revoked_by_actor_email = ai_token.attributes['revoked_by_actor_email'] rescue nil
node.value('revoked_by_actor_email', revoked_by_actor_email) if revoked_by_actor_email
end

revoked_at = ai_token.attributes['revoked_at'] rescue nil
node.value('revoked_at', revoked_at) if revoked_at

attach_account(node, account) if account
end

def inject_maintenance(
node,
command_prefix,
Expand Down
Loading
Loading