User:AnomieBOT/source/AnomieBOT/API/Toolforge.pm

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