package AnomieBOT::API::Toolforge;
use utf8;
use strict;
use AnomieBOT::API::Toolforge::Response;
use Carp;
use JSON;
use LWP::UserAgent;
use IO::Socket::SSL ();
use URI::Escape;
=pod
=head1 NAME
AnomieBOT::API::Toolforge - AnomieBOT Toolforge API access
=head1 SYNOPSIS
use AnomieBOT::API::Toolforge;
my $toolforge = AnomieBOT::API::Toolforge->new( toolname => "my-tool" );
my $jobs = $toolforge->jobs();
=head1 DESCRIPTION
C<AnomieBOT::API::Toolforge> provides some methods to interact with the
L<Toolforge API|wikitech:Help:Toolforge/API> internal endpoint.
=head1 METHODS
=head2 CONSTRUCTOR
=over
=item AnomieBOT::API::Toolforge->new( %options )
Creates a new AnomieBOT::API::Toolforge object. Available options are:
=over
=item toolname
Name of the tool.
=item crtfile
Certificate file to use rather than C<$HOME/.toolskube/client.crt>.
=item keyfile
Key file to use rather than C<$HOME/.toolskube/client.key>.
=back
=cut
sub new {
my ($class, %options) = @_;
croak 'AnomieBOT::API::Toolforge: `toolname` is required.' unless defined( $options{'toolname'} );
my $self = {
endpoint => 'https://api.svc.tools.eqiad1.wikimedia.cloud:30003',
toolname => $options{'toolname'},
ua => LWP::UserAgent->new(
agent => 'AnomieBOT/1.0 (' . $options{'toolname'} . ')',
ssl_opts => {
SSL_cert_file => $options{'crtfile'} // "$ENV{HOME}/.toolskube/client.crt",
SSL_key_file => $options{'keyfile'} // "$ENV{HOME}/.toolskube/client.key",
# Uses a self-signed cert, sigh.
SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
verify_hostname => 0,
},
),
};
bless $self, $class;
return $self;
}
=pod
=back
=head2 ATTRIBUTES
=over
=item $toolforge->toolname()
=item $toolforge->toolname( $newname )
Get or set the tool name. Returns the old name.
=cut
sub toolname {
my $self = shift;
my $old = $self->{'toolname'};
if ( @_ ) {
$self->{'toolname'} = shift;
$self->{'ua'}->agent( 'AnomieBOT/1.0 (' . $self->{'toolname'} . ')' );
}
return $old;
}
=pod
=back
=head2 GENERIC ACCESS
=over
=item $toolforge->get( $endpoint )
=item $toolforge->get( $endpoint, $header_name => $value, ... )
=item $toolforge->post( $endpoint, \%fields )
=item $toolforge->post( $endpoint, \%fields, $field_name => $value, ... )
=item $toolforge->post( $endpoint, $field_name => $value, Content => \%fields )
=item $toolforge->post( $endpoint, $field_name => $value, Content => $content )
=item $toolforge->delete( $endpoint )
=item $toolforge->delete( $endpoint, $header_name => $value, ... )
=item $toolforge->patch( $endpoint, \%fields )
=item $toolforge->patch( $endpoint, \%fields, $field_name => $value, ... )
=item $toolforge->patch( $endpoint, $field_name => $value, Content => \%fields )
=item $toolforge->patch( $endpoint, $field_name => $value, Content => $content )
=item $toolforge->put( $endpoint, \%fields )
=item $toolforge->put( $endpoint, \%fields, $field_name => $value, ... )
=item $toolforge->put( $endpoint, $field_name => $value, Content => \%fields )
=item $toolforge->put( $endpoint, $field_name => $value, Content => $content )
Make a request to an API endpoint.
Then C<$endpoint> is the API endpoint with leading slash, e.g. C</jobs/v1/healthz>.
Any additional options (i.e. form data or HTTP headers) are passed on to the corresponding
methods of C<LWP::UserAgent>. See documentation there for details.
All return an A<AnomieBOT::API::Toolforge::Reponse> object, which is a subclass of L<HTTP::Response|HTTP::Response(3pm)>
with an additional C<json_content> method that JSON-decodes the C<decoded_content>.
=cut
sub get {
my ($self, $endpoint, @opts) = @_;
croak '$endpoint does not seem to be valid' unless $endpoint =~ /^\//;
my $res = $self->{'ua'}->get( $self->{'endpoint'} . $endpoint, @opts );
bless $res, 'AnomieBOT::API::Toolforge::Reponse';
return $res;
}
sub post {
my ($self, $endpoint, @opts) = @_;
croak '$endpoint does not seem to be valid' unless $endpoint =~ /^\//;
my $res = $self->{'ua'}->post( $self->{'endpoint'} . $endpoint, @opts );
bless $res, 'AnomieBOT::API::Toolforge::Reponse';
return $res;
}
sub delete {
my ($self, $endpoint, @opts) = @_;
croak '$endpoint does not seem to be valid' unless $endpoint =~ /^\//;
my $res = $self->{'ua'}->delete( $self->{'endpoint'} . $endpoint, @opts );
bless $res, 'AnomieBOT::API::Toolforge::Reponse';
return $res;
}
sub patch {
my ($self, $endpoint, @opts) = @_;
croak '$endpoint does not seem to be valid' unless $endpoint =~ /^\//;
my $res = $self->{'ua'}->patch( $self->{'endpoint'} . $endpoint, @opts );
bless $res, 'AnomieBOT::API::Toolforge::Reponse';
return $res;
}
sub put {
my ($self, $endpoint, @opts) = @_;
croak '$endpoint does not seem to be valid' unless $endpoint =~ /^\//;
my $res = $self->{'ua'}->put( $self->{'endpoint'} . $endpoint, @opts );
bless $res, 'AnomieBOT::API::Toolforge::Reponse';
return $res;
}
=pod
=back
=head2 ENDPOINT WRAPPERS
=over
=item $toolforge->pods()
Get running pods from Kubernetes.
Pending https://phabricator.wikimedia.org/T321919#11126898.
=cut
sub pods {
my ($self) = @_;
if ( ! -f '/var/run/secrets/kubernetes.io/serviceaccount/namespace' ) {
# We're running on the bastion, shell out to kubectl.
return AnomieBOT::API::Toolforge::Reponse->new( 200, 'OK', [], scalar `kubectl get pods -o json` );
}
open X, '<:utf8', '/var/run/secrets/kubernetes.io/serviceaccount/namespace' or return AnomieBOT::API::Toolforge::Reponse->new( 500, "Failed to read /var/run/secrets/kubernetes.io/serviceaccount/namespace: $!" );
my $namespace = <X>;
close X;
my $res = $self->{'ua'}->get( "https://kubernetes.default.svc/api/v1/namespaces/$namespace/pods/" );
bless $res, 'AnomieBOT::API::Toolforge::Reponse';
return $res;
}
=pod
=item $toolforge->jobs()
Wrapper for GET C</jobs/v1/tool/{toolname}/jobs>.
=cut
sub jobs {
my ($self) = @_;
return $self->get( sprintf( '/jobs/v1/tool/%s/jobs', uri_escape_utf8( $self->{'toolname'} ) ) );
}
=pod
=item $toolforge->submit_job( %data )
Wrapper for POST C</jobs/v1/tool/{toolname}/jobs>.
=cut
sub submit_job {
my ($self, %data) = @_;
return $self->post(
sprintf( '/jobs/v1/tool/%s/jobs', uri_escape_utf8( $self->{'toolname'} ) ),
'Content-Type' => 'application/json',
Content => JSON->new->encode( \%data )
);
}
1;
=pod
=back
=cut