#=============================================================================
# Doloforge::Command::Project : add, remove project, check status
#
# Add and remove (well, not yet) what's related to projects in the Doloforge
# realm : directories, services, etc.
#
# Full help :
#   doloforge project --man
#=============================================================================


package Doloforge::Command::Project;

use strict;
use warnings;

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


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

=head1 NAME

Doloforge::Command::Project - Add, remove project, check status

=head1 SYNOPSIS

    # Add a project : full options
    doloforge project --add
    # 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 project --name sandbox --client internal --update

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

    # Force modifications, in case sub-templates disturb dependencies
    # --force and --diff can be cumulated
    doloforge project --name sandbox --client 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 project
        'remove',               # mark the project as removed, and warn the admin about risky tasks
        'list',                 # list the existing projects, in a tab-separated format, either for a single client, of for all...
        'check',                # check the configuration of existing projects (or --name'd projects)
        'update',               # update the actual configuration from client/project descriptions
                                # this is an equivalent of config->update(), and will call project->update()
        # parameters :
        'client|c=s',           # the client this projects relates to is mandatory
        'name|n=s',             # define the name (userid) of the project to act upon
        # add parameters :
        'allowmod',             # is modification of an existing project allowed by the "add" command ?
        'description|desc=s',   # description text for the project
        'email=s',              # email address of the webmaster for the project (will receive emails from Doloforge)
        'password=s',           # password for the userid (name) that will be created for various tasks (not system user, but Web/FTP/etc.)
        'friends=s',            # this project is a "friend" of "client[.,:/]project[.,:/]role ..." (multiple friends allowed)
        'services=s',           # the services run for the project (postgres, mason, apache, ftp, etc.) (all [.,:/] separated)
        # 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)
        # well-known services parameters :
        'servername=s',         # apache service ServerName directive (one name)
        'serveralias=s',        # apache service ServerAlias directive (list of \s+ separated host names)
        'backends=s',           # back-end services to use "be1[.,:/]be2[.,:/]be3" (multiple allowed) ; will select the proper reverse-proxy templates for front-end configuration
        'database=s',           # database service to use ; will select the proper DB connection templates for back-end configuration
        'subnets=s',            # ftp service allowed subnets
    );
}

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

sub add {
    my $self = shift;

    my ($option) = @_;

    # There is one arbitrary limit on project names. The same applies to service names, but these are defined by the system, not the administrator...
    $self->error("Project name forbidden: '$option->{name}'")
        if ($option->{name} eq 'lib');

    # Check if the project name is already used (for the intended client), by
    # checking if the project config file already exists.
    my $conffile = $self->_get_config_file($option->{client}, $option->{name});
    if (-f $conffile) {
        if (exists($option->{allowmod})) {
            $self->warn("The project configuration '$conffile' already exist and will be modified");
        } else {
            $self->error("Modification forbidden: the project configuration '$conffile' already exist and won't be modified unless you specify --allowmod");
        }
    }

    # Build the project config file (YAML) from a data structure
    # We do not add a subsection to the client configuration file to facilitate
    # project-by-project handling (one project, one directory, one config file)
    touch($conffile); # Create the file if it doesn't exist, because Config::YAML wants to read it first
    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 project won't ever change, and only have this initial value
        client      => $option->{client},   # ditto
        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});
    # The project allows an array of one to many "friends", all updated at once, or kept as-is if not specified on the command-line
    if ($option->{friends}) {
        $config->{friends} = [];
        # The syntax of the --friend argument is "client1[.,:/]project1 client2[.,:/]project2 ..."
        #   - for each specified pair
        foreach my $friend (split(/\s+/, $option->{friends})) {
            #   - split the pair in its components
            my ($client, $project, $role) = split(/[\.,:\/]/, $friend, 3);
            #   - check if the client/project actually pre-exist
            $self->error("This project refers to a non-existent '$client':'$project' : first create it then add this new project.")
                unless (-f $self->_get_config_file($client, $project));
            #   - add the pair as a hash in the building array : the resulting YAML is very cute...
            push(@{$config->{friends}}, { client => $client, project => $project, role => $role });
        }
    }
    # Services are in the form "service1[.,:/]service2"
    # A service name starting with '-' will be deactivated : this allows seamless adding/removing of single services to the project
    # They are taken from the list of known services
    if ($option->{services}) {
        foreach my $service (split(/[\.,:\/]/, $option->{services})) {
            # Check the presence and erase the leading -'s
            unless ($service =~ s/^-+//) {
                # Check the availability and parameters of the service
                my $params = $self->_check_service($service, $option);
                if ($params) {
                    $config->{services}->{$service} = $params if ($params);
                } else {
                    $self->warn("Unknown service '$service' : removed.");
                    delete $config->{services}->{$service};
                }
            } else {
                delete ($config->{services}->{$service});
            }
        }
    }

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

#=============================================================================
# Method remove
#
# Mark the project 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 project config file exists : if not, the project actually doesn't exist...
    my $conffile = $self->_get_config_file($option->{client}, $option->{name});
    $self->error("The project configuration '$conffile' does not exist") unless (-f $conffile);

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

    # ...and mark the project 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- project directories on each stage (for $self->{global}->{stage}) '$self->{global}->{dir}->{shared}/$option->{client}/$option->{name}'",
    );
}

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

sub list {
    my $self = shift;

    my ($option) = @_;

    # Get the complete list of projects, for a given client or for all...
    my $projects = $self->_list('all', $option->{client});
    foreach (sort(keys(%{$projects}))) {
        my $project = $projects->{$_};
        print "$project->{name}\t$project->{active}\t$project->{client}\t$project->{description}\n";
    }
}

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

sub check {
    my $self = shift;

    my ($option) = @_;
    $self->error("check: --client parameter is mandatory") unless ($option->{client});
    $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->error("update: client parameter is mandatory") unless ($option->{client});
    $self->_update_or_check(1, $option);
}

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

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

    # The client this project relates to must already exist (by it's configuration file)
    $self->error("The client '$client' config file does not exist.")
        unless (-f $self->_get_client_config_file($client));

    # 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]$/);
    # FIXME : format check vs. D::C or D::C::Client class-method ???

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

# FIXME : should be in D::C or D::C::Client
sub _get_client_config_file {
    my $self = shift;
    my ($client) = @_;

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

# Check if a service actually exist and set it's default parameters
# FIXME : should be implemented in the Doloforge::Service::* classes

sub _check_service {
    my $self = shift;
    my ($service, $option) = @_;

    my $params = {
        apache          => {},
        mason           => {},
        php5            => {},
        iis             => {}, # FIXME : configure the reverse-proxy hostname and URLs to rewrite, set the IIS ID (random) of the website, in order to get the logs
        postgres        => {
            dbname      => "$option->{client}_$option->{name}",
        },
        sqlserver       => {},
        mysql           => {
            dbname      => "$option->{client}_$option->{name}",
        },
        ftp             => {
            username    => "$option->{client}_$option->{name}",
        },
        cron            => {},
        bulkmail        => {},
        tomcat          => {},
    };
    $params->{apache}->{servername} = $option->{servername} if ($option->{servername});
    push(@{$params->{apache}->{serveralias}}, split(/\s+/, $option->{serveralias})) if ($option->{serveralias});
    # FIXME : mettre tous les back-end possibles configurés si on n'en demande pas un spécial (service mason, php5, iis => backends par défaut)
    push(@{$params->{apache}->{backends}}, split(/[\.,:\/]/, $option->{backends})) if ($option->{backends});
    $params->{mason}->{database} = $option->{database} if ($option->{database});
    $params->{php5}->{database} = $option->{database} if ($option->{database});
    push(@{$params->{ftp}->{subnets}}, split(/\s+/, $option->{subnets})) if ($option->{subnets});

    return $params->{$service};
}

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

sub _list {
    my $self = shift;

    # By default, only include active projects, just in case the caller forgets projects can be inactive and thus should not be handled
    my $include = shift || 'active';
    # Include only projects for the given client, or all clients
    # Beware : an inactive client can have active projects which will still be listed !!!
    my $client = shift || '*';
    my $project = shift || '*';

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

    return $projects;
}

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

sub _update_or_check {
    my $self = shift;

    my ($update, $option, $client_conf) = @_;
    # Work on the given client (mandatory);
    my $client = $option->{client};
    $self->error("_update_or_check : missing client") unless ($client);

    # Read the client config file if it wasn't read by the calling Doloforge::Command::Client
    $client_conf = Config::YAML->new(config => $self->_get_client_config_file($client))
        unless $client_conf;

    # Get the name of the only project to act upon, or loop around each projects.
    # 20190126/FIXME : doesn't seem to work, regenerates all projects, probably because of --force ?
    #   root@sodium:~# doloforge project --name books --client jle --update --diff --force
    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 project : get it's config from it's name
        $self->_update($update, $option, $client_conf, Config::YAML->new(config => $self->_get_config_file($client, $name)));
    } else {
        # Get the complete list of clients to update : the config files are already read
        # Beware : keys in the %projects hash are not the actual project names, but a concatenation of "client/projet"
        my $projects = $self->_list('active', $client);
        foreach $name (sort(keys(%{$projects}))) {
            $self->_update($update, $option, $client_conf, $projects->{$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 project, depending on client/project config files
# Tasks for a project are :
#   - add /srv/doloforge/*/[stage]/[client]/[project]/
#   - add services directories and templates (services are based on a template system : apache->fe, mason/php5->be, rsync, ftp)
#   - merge the configuration and the template, to create a config file for the service
#   - reload modified service daemons (if needed)
#
#        - ajoute /srv/doloforge/*/[stage]/[client]/[project]/
#        - crée les répertoires services dedans
#        - repose sur un jeu de templates de services (apache->fe, mason/php5->be, rsync, ftp)
#        - régénère les conf par service (fusion de modèle générique ou spécifique et de la conf)
#        - relance les services modifiés
#        - site PHP basique : fe + be fusionnés au biveau FS, mais pas au niveau Apache = lien symbolique relatif public/

sub _update {
    my $self = shift;

    my ($update, $option, $client_conf, $project_conf) = @_;
    
    # Check for the presence of the client/project 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}/$option->{client}/$project_conf->{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 (only services directories will be developer-writable)
            "$self->{global}->{dir}->{skel}/project/shared",                            # skeleton to copy upon creation of the directory
            $self->{global}->{project}->{developerdirs},                                # developer directories which must be group-writable by developers, thus chown'ed
            ($client_conf->{uid} or $client_conf->{name}),                              # user/group id of the client who owns this project
            ($client_conf->{gid} or $client_conf->{name}),                              # user/group id of the client who owns this project
        );
    }

    # TODO : add developerdirs permissions to lib/*/ and some others

    if ($update) {
        # Call each service's configuration system, then the generic 'project' service, which can handle every config file which do not really fit in any service (Analog config, etc.)
        # FIXME : even with --force, should prevent a $merged file to be generated more than once (ie. with the apache service, and then with the project service)
        foreach my $service (sort(keys(%{$project_conf->{services}})), 'project') {
            # Dynamically create a new instance of the Doloforge::Service::$service class
            # in order to update each project's services...
            # Do not re-create a new service instance for each project : just keep them in cache (as a property of the project)
            unless ($self->{service_instance}->{$service}) {
                $self->{service_instance}->{$service} = &Doloforge::Service::Factory($service, $self->{global});
                # Initialize the service objet for future updates. This will trigger the finish() method upon destruction of the object.
                $self->{service_instance}->{$service}->begin();
            }
            # ajout du project dans le service
# FIXME : call ->remove() if the project is "active=0" (or client too)
            # Call the update method of the service class, which will do it's job to configure the service for the project.
            $self->{service_instance}->{$service}->update($option, $client_conf, $project_conf);
        }
    }
}



1;
__END__

=head1 AUTHOR

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

=head1 SEE ALSO

doloforge(1)

=cut
