#=============================================================================
# Doloforge::Command::Client : add, remove client, check status
#
# Add and remove (well, not yet) what's related to clients in the Doloforge
# realm : users, groups, directories, etc.
#
# Full help :
#   doloforge client --man
#=============================================================================


package Doloforge::Command::Client;

use strict;

# This base class implements the constructor and help methods in a generic way.
use base qw(Doloforge::Command);
use File::Touch;


#=============================================================================
=encoding utf8

=head1 NAME

Doloforge::Command::Client - Add, remove client, check status

=head1 SYNOPSIS

    FIXME help page
    # Add a client : full options
    doloforge client --add --name internal [--allowmod] --description 'My description' --email 'webmaster@email.net' --password dlrkmiuh
    # The project conf file generated must be dispatched to the whole cluster
    csync2 -xv
    # Must be followed by, on each host in the cluster :
    doloforge client --name internal --update

    # Review the modifications before actually making them
    doloforge client --name internal --update --diff

    # Force modifications, in case sub-templates disturb dependencies
    # --force and --diff can be cumulated
    doloforge client --name internal --update --force

=head1 DESCRIPTION

Todo

=cut


#=============================================================================
# Method getOptions
#
# Accepted arguments from the command-line, in the form accepted by Getopt::Long.
# Options with no value that match method names of that package will be called as
# methods, with the decoded options as parameters.

sub getOptions {
    return (
        # actions :
        'add',                  # add a new --name'd client
        'remove',               # mark the client as removed, and warn the admin about risky tasks
        'list',                 # list the existing clients, in a tab-separated format
        'check',                # check the configuration of existing clients (or --name'd client)
        'update',               # update the actual configuration from client/project descriptions
                                # this is an equivalent of config->update(), and will call project->update()
        # parameters :
        'name|n=s',             # define the name (userid) of the client to act upon
        # add parameters :
        'allowmod',             # is modification of an existing client allowed by the "add" command ?
        'description|desc=s',   # description text for the client
        'email=s',              # email address of the webmaster for the client (will receive generic emails from Doloforge)
        'password=s',           # password for the userid (name) that will be created for various tasks (not system user, but Web/FTP/etc.)
        # update parameters :
        'diff',                 # display a diff of every file during update, instead of simply telling how many were changed
        'force',                # force regeneration, regardless of timestamps (works around dependency problems related to sub-templates)
    );
}

#=============================================================================
# Method add
#
# Add a new client to the system, creating it's description file. The client
# will be actually created upon "config --update".

sub add {
    my $self = shift;

    my ($option) = @_;

    # Check if the client username is already used, by checking if
    # the client config file already exists.
    my $conffile = $self->_get_config_file($option->{name});
    if (-f $conffile) {
        if (exists($option->{allowmod})) {
            $self->warn("The client configuration '$conffile' already exist and will be modified");
        } else {
            $self->error("Modification forbidden : the client configuration '$conffile' already exist and won't be modified unless you specify --allowmod");
        }
    } else {
        # If the $name is not already used by a previous client, check if $name exist
        # in /etc/passwd (may be a regular previous user/developer).
        # In this case, we can do nothing more...
        $self->error("The userid '$option->{name}' already exist on that host, please select another name.") if (defined(getpwnam($option->{name})));
        $self->error("The groupid '$option->{name}' already exist on that host, please select another name.") if (defined(getgrnam($option->{name})));
    }

    # Build the client config file (YAML) from a data structure
    touch($conffile); # Create the file if it doesn't exist, because Config::YAML wants to read it
    my $config = Config::YAML->new(
        # The config file : subdirectory of the general config file directory
        config      => $conffile,
        # Initial configuration values, overridden by the ones in the config file itself
        name        => $option->{name},     # the name of the client won't ever change, and only have this initial value
        date        => {
            created     => $self->now(),    # ditto for the date:created : this current value is overrided by the config file
        },
    );

    # We don't use the $config->fold() method, because sub-hashes added this way destroy the ones read from the file, loosing immutable values
    $config->{active} = 1;
    $config->{date}->{modified} = $self->now();
    $config->{description} = $option->{description} if ($option->{description});
    $config->{webmaster}->{email} = $option->{email} if ($option->{email});
    $config->{webmaster}->{password} = $option->{password} if ($option->{password});
    # Identify "the nbext uid/gid" from every existing /srv/doloforge/[stage]/[client]/lib/conf (for instance)
    # We can't always do this on the server which holds the usernames, so this is an nearly good enough solution.
    #   $self->{global}->{dir}->{shared}/$name . $self->{global}->{client}->{developerdirs}->[0]
    #   ls -ldn /srv/doloforge/devl/*/lib/conf | awk '($3 > uid){uid = $3} ($4 > gid){gid = $4} END{ print uid+1, gid+1;}'
    my ($uid, $gid) = (0, 0);
    foreach (glob("$self->{global}->{dir}->{shared}/*/$self->{global}->{client}->{developerdirs}->[0]")) {
        my ($u, $g) = (stat($_))[4,5];
        $uid = $u if $u > $uid;
        $gid = $g if $g > $gid;
        #print "$uid:$gid\t$u\t$g\t$_\n";
    }
    $config->{uid} = $uid + 1 if ($uid and not exists($config->{uid}));
    $config->{gid} = $gid + 1 if ($gid and not exists($config->{gid}));

    # Write configuration state to disk
    $config->write;

    # TODO : create the ssh key
    #   ssh-keygen -N "" -f /etc/doloforge/keys/client.dolomede.id_rsa
    #   global->commands->keygen
}

#=============================================================================
# Method remove
#
# Mark the client as "not active" in the configuration file. It's the admin's
# responsability to actually remove files and uid/gid.

sub remove {
    my $self = shift;

    my ($option) = @_;

    # Check if the client config file exists : if not, the client actually doesn't exist...
    my $conffile = $self->_get_config_file($option->{name});
    $self->error("The client configuration '$conffile' does not exist") unless (-f $conffile);

    # Retrieve the client config file (YAML)...
    my $config = Config::YAML->new(config => $conffile);

    # ...and mark the client as not active
    $config->{active} = 0;

    # Write configuration state to disk
    $config->write;

    # Warn the admin about pending tasks
    $self->message(
        "You still have to manually remove the following things:",
        "\t- user '$option->{name}' and group '$option->{name}'",
        "\t- home directory '$self->{global}->{dir}->{users}/$option->{name}/'",
        "\t- client directories on each stage (for $self->{global}->{stage}) '$self->{global}->{dir}->{shared}/$option->{name}'",
    );
}

#=============================================================================
# Method list
#
# List the existing clients from the config file presence and "active" value.

sub list {
    my $self = shift;

    my ($option) = @_;

    # Get the complete list of clients
    my $clients = $self->_list('all');
    foreach (sort(keys(%{$clients}))) {
        my $client = $clients->{$_};
        print "$client->{name}\t$client->{active}\t$client->{description}\n";
    }
}

#=============================================================================
# Method check
#
# Check the named client or all clients for things that --update will do

sub check {
    my $self = shift;

    my ($option) = @_;
    $self->_update_or_check(0, $option);
}

#=============================================================================
# Method update
#
# Update the configuration on the host, depending on client/project config files
# This method must be run of each host, and will decide what to do from cluster
# information (the roles of the running host)

sub update {
    my $self = shift;

    $self->error("update: must be run as root (try su, sudo, etc.)")
        unless ($self->check_uid('root'));

    my ($option) = @_;
    $self->_update_or_check(1, $option);
}

#=============================================================================
# Private methods
#
# Get the config file name for the client, and by the way, check the client name...

sub _get_config_file {
    my $self = shift;
    my ($name) = @_;

    # The name must conform to DNS naming rules, and be 2 chars long at least
    $self->error("Wrong name format '$name' (letter + letter/digit/hyphen, no hyphen at the end).") unless ($name =~ /^[a-zA-Z][a-zA-Z0-9-]*?[a-zA-Z0-9]$/);

    # Build the client config file path from the global configuration...
    return "$self->{global}->{dir}->{projconf}/client.$name.yaml";
}

# List the existing client config names and return their file contents
# First parameter = 'all', 'active', 'inactive' : include all, only (in)active clients
# Any other value will include none...

sub _list {
    my $self = shift;

    # By default, only include active clients, just in case the caller forgets clients can be inactive and thus should not be handled
    my $include = shift || 'active';
    # Include only the given client, or all clients
    my $client = shift || '*';

    my $clients;
    foreach my $conf (glob("$self->{global}->{dir}->{projconf}/client.$client.yaml")) {
        if (-f $conf) {
            # Read the client config file, in order to check for "active" status...
            my $client_config = Config::YAML->new(config => $conf);
            # ...and return the full config, just because it was a hard job to read it
            $clients->{$client_config->{name}} = $client_config
                if (($include eq 'all') || (($include eq 'active') && $client_config->{active}) || (($include eq 'inactive') && ! $client_config->{active}));
        }
    }

    return $clients;
}

# Update or simply check the configuration on the host, depending on client/project config files
# Run the update or check on the named client, or on all clients

sub _update_or_check {
    my $self = shift;

    my ($update, $option) = @_;
    # Get the name of the only client to act upon, or loop around each client.
    my $name = $option->{name};
    # The options will be passed to "child" classes, so we must erase parameters with obvious unexpected behaviour
    delete $option->{name};
    
    if ($name) {
        # Update only a single client : get it's config from it's name
        $self->_update($update, $option, $name, Config::YAML->new(config => $self->_get_config_file($name)));
    } else {
        # Get the complete list of clients to update : the config files are already read
        my $clients = $self->_list('active');
        foreach $name (sort(keys(%{$clients}))) {
            $self->_update($update, $option, $name, $clients->{$name});
        }
    }

    # Restore the "name" option, in case it's needed for another stage
    $option->{name} = $name if ($name);
}

# Update or check the configuration on the host for a single client, depending on client/project config files
# Tasks for a client are :
#   - add /srv/doloforge/*/[stage]/[client]/
#   - create uid/gid
#   - update or check every projects of that client

sub _update {
    my $self = shift;

    my ($update, $option, $name, $config) = @_;
    
    # Check for the presence of the client's uid/gid, if we're on a server allowed to do that
    if ($self->has_role('account_role') and not (getpwnam($name) and getgrnam($name))) {
        if ($update) {
            # Create the user, using the Doloforge specific adduser.conf which:
            #   - sets the FIRST_[GU]ID to higher values than standard adduser.conf,
            #     allowing uid/gid re-creation on other hosts, being confident that
            #     new users will have the same id than on the first one (if created
            #     in the same order)
            #   - sets USERGROUPS, to automatically create a personnal group for
            #     each new user. The personnal group should have the gid = uid...
            my $homedir = "$self->{global}->{dir}->{clients}/$name";
            system('/usr/sbin/addgroup',
                '--conf', "$self->{global}->{dir}->{conf}/addclient.conf",
                '--gid', $config->{gid},
                $name) if ($config->{gid});
            system('/usr/sbin/adduser',
                '--conf', "$self->{global}->{dir}->{conf}/addclient.conf",
                '--disabled-password',
                ($config->{uid} ? ('--uid', $config->{uid}) : ()),
                ($config->{gid} ? ('--gid', $config->{gid}) : ()),
                '--home', $homedir,
                '--gecos', $self->gecos_string($config->{description} || $name, 'client', undef, undef, $self->now()),
                $name);
            # chmod g+ws on client home dir... only on the host on which developers are created...
            # adduser.conf may cause g-w permission, which is good for developers, but not for clients, which goal is cooperation between developers
            # (perfunc(1) : "...the mode contains both the file type and its permissions, you should mask off the file type portion...")
            chmod((stat($homedir))[2] & 07777 | 02020, $homedir) if ($self->has_role('user_role'));
        } else {
            $self->message("Missing client user uid='$name' or group gid='$name'");
        }
    }

    # Check for the presence of the client directories : path, permission (octal in the config file), group name
    if ($self->has_role('nfs_role')) {
        $self->dir_entry($update, "$self->{global}->{stage} shared",        # check or update, directory description in messages
            "$self->{global}->{dir}->{shared}/$name",                       # directory name
            oct($self->{global}->{permissions}->{shared}->[0]),             # permission on creation and update (in decimal form, hence the oct())
            "-$self->{global}->{permissions}->{user}->[0]",                 # uid doesn't matter, hence the dash
            $self->{global}->{permissions}->{group}->[0],                   # group is the generic doloforge group
            "$self->{global}->{dir}->{skel}/client/shared",                 # skeleton to copy upon creation of the directory
            $self->{global}->{client}->{developerdirs},                     # developer directories which must be group-writable by developers, thus chown'ed
            ($config->{uid} or $name), ($config->{gid} or $name),           # user/group id of the client who own this project
        );
        # Only root can do that, because members of the doloforge groups (admins) cannot change group ownership of the projects, as they do not belong to that new client group.
        # Even if they were added to the client group created above, they wouldn't be able to chgrp, because secondary groups are evaluated at login-time...
    }

    # TODO : add developerdirs permissions to lib/*/ and some others
    # TODO : copy the ssh key + authorized_keys from /etc/doloforge/keys to /home/client/[client], if it was modified, then change permissions

    # Dynamically create a new instance of the Doloforge::Command::Project class
    # in order to check/update each client's projects...
    # Do not re-create a new generic project instance for each client : just keep it in cache (as a property of the client)
    $self->{project_instance} ||= &Doloforge::Command::Factory('project', $self->{global});

    # Call the update method of the project class, which will update all projects by default.
    # In each call, add the client name the project belongs to.
    # We just hope that local options will not create unexpected behaviour of the "child" method
    delete $option->{name};
    $self->{project_instance}->_update_or_check($update, {%$option, client => $name}, $config);
}



1;
__END__

=head1 AUTHOR

Nicolas Huillard <F<nicolas@huillard.net>>, Dolomède

=head1 SEE ALSO

doloforge(8)

=cut
