/*
 * The MIT License
 *
 * Copyright (c) 2004-2009, Sun Microsystems, Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package hudson.os.solaris;

import com.sun.akuma.Daemon;
import com.sun.akuma.JavaVMArguments;
import hudson.Launcher.LocalLauncher;
import hudson.Util;
import hudson.Extension;
import hudson.Functions;
import jenkins.util.SystemProperties;
import hudson.os.SU;
import hudson.model.AdministrativeMonitor;
import jenkins.model.Jenkins;
import hudson.model.TaskListener;
import hudson.util.ForkOutputStream;
import hudson.util.HudsonIsRestarting;
import hudson.util.StreamTaskListener;
import static hudson.util.jna.GNUCLibrary.*;

import jenkins.security.MasterToSlaveCallable;
import org.apache.commons.io.output.ByteArrayOutputStream;
import org.jvnet.libpam.impl.CLibrary.passwd;
import org.jvnet.solaris.libzfs.ACLBuilder;
import org.jvnet.solaris.libzfs.LibZFS;
import org.jvnet.solaris.libzfs.ZFSException;
import org.jvnet.solaris.libzfs.ZFSFileSystem;
import org.jvnet.solaris.libzfs.ErrorCode;
import org.jvnet.solaris.mount.MountFlags;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;
import org.kohsuke.stapler.HttpRedirect;
import org.kohsuke.stapler.interceptor.RequirePOST;

import javax.servlet.ServletException;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.io.Serializable;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Encourages the user to migrate JENKINS_HOME on a ZFS file system.
 *
 * @author Kohsuke Kawaguchi
 * @since 1.283
 */
public class ZFSInstaller extends AdministrativeMonitor implements Serializable {
    private static final long serialVersionUID = 1018007614648118323L;

    /**
     * True if $JENKINS_HOME is a ZFS file system by itself.
     */
    private final boolean active = shouldBeActive();

    /**
     * This will be the file system name that we'll create.
     */
    private String prospectiveZfsFileSystemName;

    public boolean isActivated() {
        return active;
    }

    public boolean isRoot() {
        return LIBC.geteuid()==0;
    }

    public String getProspectiveZfsFileSystemName() {
        return prospectiveZfsFileSystemName;
    }

    private boolean shouldBeActive() {
        if(!System.getProperty("os.name").equals("SunOS") || disabled)
            // on systems that don't have ZFS, we don't need this monitor
            return false;

        try {
            LibZFS zfs = new LibZFS();
            List<ZFSFileSystem> roots = zfs.roots();
            if(roots.isEmpty())
                return false;       // no active ZFS pool

            // if we don't run on a ZFS file system, activate
            ZFSFileSystem hudsonZfs = zfs.getFileSystemByMountPoint(Jenkins.getInstance().getRootDir());
            if(hudsonZfs!=null)
                return false;       // already on ZFS

            // decide what file system we'll create
            ZFSFileSystem pool = roots.get(0);
            prospectiveZfsFileSystemName = computeHudsonFileSystemName(zfs,pool);

            return true;
        } catch (Exception e) {
            LOGGER.log(Level.WARNING, "Failed to detect whether Hudson is on ZFS",e);
            return false;
        } catch (LinkageError e) {
            LOGGER.info("No ZFS available. If you believe this is an error, increase the logging level to get the stack trace");
            LOGGER.log(Level.FINE,"Stack trace of failed ZFS load",e);
            return false;
        }
    }

    /**
     * Called from the management screen.
     */
    @RequirePOST
    public HttpResponse doAct(StaplerRequest req) throws ServletException, IOException {
        Jenkins.getInstance().checkPermission(Jenkins.ADMINISTER);

        if(req.hasParameter("n")) {
            // we'll shut up
            disable(true);
            return HttpResponses.redirectViaContextPath("/manage");
        }

        return new HttpRedirect("confirm");
    }

    /**
     * Creates a ZFS file system to migrate the data to.
     *
     * <p>
     * This has to be done while we still have an interactive access with the user, since it involves the password.
     *
     * <p>
     * An exception will be thrown if the operation fails. A normal completion means a success.
     *
     * @return
     *      The ZFS dataset name to migrate the data to.
     */
    private String createZfsFileSystem(final TaskListener listener, String rootUsername, String rootPassword) throws IOException, InterruptedException, ZFSException {
        // capture the UID that Hudson runs under
        // so that we can allow this user to do everything on this new partition
        final int uid = LIBC.geteuid();
        final int gid = LIBC.getegid();
        passwd pwd = LIBC.getpwuid(uid);
        if(pwd==null)
            throw new IOException("Failed to obtain the current user information for "+uid);
        final String userName = pwd.pw_name;

        final File home = Jenkins.getInstance().getRootDir();

        // this is the actual creation of the file system.
        // return true indicating a success
        return SU.execute(listener, rootUsername, rootPassword, new MasterToSlaveCallable<String,IOException>() {
            private static final long serialVersionUID = 7731167233498214301L;

            public String call() throws IOException {
                PrintStream out = listener.getLogger();

                LibZFS zfs = new LibZFS();
                ZFSFileSystem existing = zfs.getFileSystemByMountPoint(home);
                if(existing!=null) {
                    // no need for migration
                    out.println(home+" is already on ZFS. Doing nothing");
                    return existing.getName();
                }

                String name = computeHudsonFileSystemName(zfs, zfs.roots().get(0));
                out.println("Creating "+name);
                ZFSFileSystem hudson = zfs.create(name, ZFSFileSystem.class);

                // mount temporarily to set the owner right
                File dir = Util.createTempDir();
                hudson.setMountPoint(dir);
                hudson.mount();
                if(LIBC.chown(dir.getPath(),uid,gid)!=0)
                    throw new IOException("Failed to chown "+dir);
                hudson.unmount();

                try {
                    hudson.setProperty("hudson:managed-by","hudson"); // mark this file system as "managed by Hudson"

                    ACLBuilder acl = new ACLBuilder();
                    acl.user(userName).withEverything();
                    hudson.allow(acl);
                } catch (ZFSException e) {
                    // revert the file system creation
                    try {
                        hudson.destory();
                    } catch (Exception _) {
                        // but ignore the error and let the original error thrown
                    }
                    throw e;
                }
                return hudson.getName();
            }
        });
    }

    /**
     * Called from the confirmation screen to actually initiate the migration.
     */
    @RequirePOST
    public void doStart(StaplerRequest req, StaplerResponse rsp, @QueryParameter String username, @QueryParameter String password) throws ServletException, IOException {
        Jenkins hudson = Jenkins.getInstance();
        hudson.checkPermission(Jenkins.ADMINISTER);

        final String datasetName;
        ByteArrayOutputStream log = new ByteArrayOutputStream();
        StreamTaskListener listener = new StreamTaskListener(log);
        try {
            datasetName = createZfsFileSystem(listener,username,password);
        } catch (Exception e) {
            Functions.printStackTrace(e, listener.error(e.getMessage()));

            if (e instanceof ZFSException) {
                ZFSException ze = (ZFSException) e;
                if(ze.getCode()==ErrorCode.EZFS_PERM) {
                    // permission problem. ask the user to give us the root password
                    req.setAttribute("message",log.toString());
                    rsp.forward(this,"askRootPassword",req);
                    return;
                }
            }

            // for other kinds of problems, report and bail out
            req.setAttribute("pre",true);
            sendError(log.toString(),req,rsp);
            return;
        }

        // file system creation successful, so restart

        hudson.servletContext.setAttribute("app",new HudsonIsRestarting());
        // redirect the user to the manage page
        rsp.sendRedirect2(req.getContextPath()+"/manage");

        // asynchronously restart, so that we can give a bit of time to the browser to load "restarting..." screen.
        new Thread("restart thread") {
            @Override
            public void run() {
                try {
                    Thread.sleep(5000);

                    // close all descriptors on exec except stdin,out,err
                    int sz = LIBC.getdtablesize();
                    for(int i=3; i<sz; i++) {
                        int flags = LIBC.fcntl(i, F_GETFD);
                        if(flags<0) continue;
                        LIBC.fcntl(i, F_SETFD,flags| FD_CLOEXEC);
                    }

                    // re-exec with the system property to indicate where to migrate the data to.
                    // the 2nd phase is implemented in the migrate method.
                    JavaVMArguments args = JavaVMArguments.current();
                    args.setSystemProperty(ZFSInstaller.class.getName()+".migrate",datasetName);
                    Daemon.selfExec(args);
                } catch (InterruptedException | IOException e) {
                    LOGGER.log(Level.SEVERE, "Restart failed",e);
                }
            }
        }.start();
    }

    @Extension
    public static AdministrativeMonitor init() {
        String migrationTarget = SystemProperties.getString(ZFSInstaller.class.getName() + ".migrate");
        if(migrationTarget!=null) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            StreamTaskListener listener = new StreamTaskListener(new ForkOutputStream(System.out, out));
            try {
                if(migrate(listener,migrationTarget)) {
                    // completed successfully
                    return new MigrationCompleteNotice();
                }
            } catch (Exception e) {
                // if we let any exception from here, it will prevent Hudson from starting.
                Functions.printStackTrace(e, listener.error("Migration failed"));
            }
            // migration failed
            return new MigrationFailedNotice(out);
        }

        // install the monitor if applicable
        ZFSInstaller zi = new ZFSInstaller();
        if(zi.isActivated())
            return zi;

        return null;
    }

    /**
     * Migrates $JENKINS_HOME to a new ZFS file system.
     *
     * TODO: do this in a separate JVM to elevate the privilege.
     *
     * @param listener
     *      Log of migration goes here.
     * @param target
     *      Dataset to move the data to.
     * @return
     *      false if a migration failed.
     */
    private static boolean migrate(TaskListener listener, String target) throws IOException, InterruptedException {
        PrintStream out = listener.getLogger();

        File home = Jenkins.getInstance().getRootDir();
        // do the migration
        LibZFS zfs = new LibZFS();
        ZFSFileSystem existing = zfs.getFileSystemByMountPoint(home);
        if(existing!=null) {
            out.println(home+" is already on ZFS. Doing nothing");
            return true;
        }

        File tmpDir = Util.createTempDir();

        // mount a new file system to a temporary location
        out.println("Opening "+target);
        ZFSFileSystem hudson = zfs.open(target, ZFSFileSystem.class);
        hudson.setMountPoint(tmpDir);
        hudson.setProperty("hudson:managed-by","hudson"); // mark this file system as "managed by Hudson"
        hudson.mount();

        // copy all the files
        out.println("Copying all existing data files");
        if(system(home,listener, "/usr/bin/cp","-pR",".", tmpDir.getAbsolutePath())!=0) {
            out.println("Failed to copy "+home+" to "+tmpDir);
            return false;
        }

        // unmount
        out.println("Unmounting "+target);
        hudson.unmount(MountFlags.MS_FORCE);

        // move the original directory to the side
        File backup = new File(home.getPath()+".backup");
        out.println("Moving "+home+" to "+backup);
        if(backup.exists())
            Util.deleteRecursive(backup);
        if(!home.renameTo(backup)) {
            out.println("Failed to move your current data "+home+" out of the way");
        }

        // update the mount point
        out.println("Creating a new mount point at "+home);
        if(!home.mkdir())
            throw new IOException("Failed to create mount point "+home);

        out.println("Mounting "+target);
        hudson.setMountPoint(home);
        hudson.mount();

        out.println("Sharing "+target);
        try {
            hudson.setProperty("sharesmb","on");
            hudson.setProperty("sharenfs","on");
            hudson.share();
        } catch (ZFSException e) {
            listener.error("Failed to share the file systems: "+e.getCode());
        }

        // delete back up
        out.println("Deleting "+backup);
        if(system(new File("/"),listener,"/usr/bin/rm","-rf",backup.getAbsolutePath())!=0) {
            out.println("Failed to delete "+backup.getAbsolutePath());
            return false;
        }

        out.println("Migration completed");
        return true;
    }

    private static int system(File pwd, TaskListener listener, String... args) throws IOException, InterruptedException {
        return new LocalLauncher(listener).launch().cmds(args).stdout(System.out).pwd(pwd).join();
    }

    private static String computeHudsonFileSystemName(LibZFS zfs, ZFSFileSystem top) {
        if(!zfs.exists(top.getName()+"/hudson"))
            return top.getName()+"/hudson";
        for( int i=2; ; i++ ) {
            String name = top.getName() + "/hudson" + i;
            if(!zfs.exists(name))
                return name;
        }
    }

    /**
     * Used to indicate that the migration was completed successfully.
     */
    public static final class MigrationCompleteNotice extends AdministrativeMonitor {
        public boolean isActivated() {
            return true;
        }
    }

    /**
     * Used to indicate a failure in the migration.
     */
    public static final class MigrationFailedNotice extends AdministrativeMonitor {
        ByteArrayOutputStream record;

        MigrationFailedNotice(ByteArrayOutputStream record) {
            this.record = record;
        }

        public boolean isActivated() {
            return true;
        }
        
        public String getLog() {
            return record.toString();
        }
    }

    private static final Logger LOGGER = Logger.getLogger(ZFSInstaller.class.getName());

    /**
     * Escape hatch in case JNI calls fatally crash, like in HUDSON-3733.
     */
    public static boolean disabled = SystemProperties.getBoolean(ZFSInstaller.class.getName()+".disabled");
}
