// Package iso provides support for generating ISO images.
package iso

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import (
	"database/sql"
	"errors"
	"fmt"
	"net"
	"net/http"
	"os/exec"
	"path/filepath"
	"strings"

	"github.com/apache/trafficcontrol/v8/lib/go-log"
	"github.com/apache/trafficcontrol/v8/lib/go-rfc"
	"github.com/apache/trafficcontrol/v8/lib/go-util"
	"github.com/apache/trafficcontrol/v8/traffic_ops/traffic_ops_golang/api"
	"github.com/apache/trafficcontrol/v8/traffic_ops/traffic_ops_golang/auth"
	"github.com/jmoiron/sqlx"
)

// Various directories and filenames related to ISO generation.
const (
	cfgDefaultDir   = "/var/www/files"  // Default directory containing config file
	cfgFilename     = "osversions.json" // The JSON config file containing mapping of OS names to directories
	cfgFilenamePerl = "osversions.cfg"  // Config file name in the Perl version

	// This is the directory name inside each OS directory where
	// configuration files for kickstart scripts are placed.
	ksCfgDir = "ks_scripts"

	// Configuration files that are generated inside the ks_scripts directory.
	ksCfgNetwork     = "network.cfg"
	ksCfgMgmtNetwork = "mgmt_network.cfg"
	ksCfgPassword    = "password.cfg"
	ksCfgDisk        = "disk.cfg"
	ksStateOut       = "state.out"

	ksAltCommand = "generate" // Optional executable that is invoked instead of mkisofs
)

// Various database columns and values.
const (
	ksFilesParamName       = "kickstart.files.location"
	ksFilesParamConfigFile = "mkisofs"
)

// ISOs handler is responsible for generating and returning an ISO image,
// as a streaming download.
//
// Response types:
//
// Error:
//
//	HTTP 400
//	{
//	  "alerts": [
//	    {"level":"error","text":"hostName is required"},
//	    {"level":"error","text":"disk is required"},
//	    ...,
//	  ]
//	}
//
// Success:
//
//	HTTP 200
//	Content-Disposition: attachment; filename="db.infra.ciab.test-centos72.iso"
//	Content-Type: application/download
func ISOs(w http.ResponseWriter, req *http.Request) {
	inf, userErr, sysErr, errCode := api.NewInfo(req, nil, nil)
	if userErr != nil || sysErr != nil {
		api.HandleErr(w, req, inf.Tx.Tx, errCode, userErr, sysErr)
		return
	}
	defer inf.Close()
	ir := isoRequest{}

	if err := api.Parse(req.Body, inf.Tx.Tx, &ir); err != nil {
		api.HandleErr(w, req, inf.Tx.Tx, http.StatusBadRequest, err, nil)
		return
	}

	isos(w, req, inf.Tx, inf.User, ir)
}

// cmdOverwriteCtxKey is used in an http.Request's context
// to set a cmd override value.
var cmdOverwriteCtxKey struct{}

// isos performs the majority of work for the /isos endpoint handler. It is separated out from
// the exported handler for testability.
func isos(w http.ResponseWriter, req *http.Request, tx *sqlx.Tx, user *auth.CurrentUser, ir isoRequest) {

	// Ensure that the given OSVersionDir is defined in the osversions.json config
	// file as a valid directory. This directory is later referenced for ISO creation
	// and therefore must an allowed value.
	if ok, err := ir.validateOSDir(tx); err != nil {
		api.HandleErr(w, req, tx.Tx, http.StatusInternalServerError, nil, fmt.Errorf("unable to read osversions configuration: %v", err))
		return
	} else if !ok {
		api.HandleErr(w, req, tx.Tx, http.StatusBadRequest, fmt.Errorf("invalid OS version directory: %q", ir.OSVersionDir), nil)
		return
	}

	// Determine the kickstart root directory, which is either a default
	// value or may be overridden by a database/Parameter entry.
	ksDir, err := kickstarterDir(tx, ir.OSVersionDir)
	if err != nil {
		api.HandleErr(w, req, tx.Tx, http.StatusInternalServerError, nil, fmt.Errorf("unable to determine kickstarter directory: %v", err))
		return
	}

	// cfgDir holds the kickstart config files within the root
	// kickstart directory.
	cfgDir := filepath.Join(ksDir, ksCfgDir)
	log.Infof("cfg_dir: %s", cfgDir)

	genISOCmd, err := newStreamISOCmd(ksDir)
	if err != nil {
		api.HandleErr(w, req, tx.Tx, http.StatusInternalServerError, nil, fmt.Errorf("unable to initialize genISO command: %v", err))
		return
	}
	defer genISOCmd.cleanup()

	// Allow for the request context to carry a modifier function that can change the
	// genISOCmd's command. This is purely used for testing.
	if cmdMod, ok := req.Context().Value(cmdOverwriteCtxKey).(func(in *exec.Cmd) *exec.Cmd); ok {
		genISOCmd.cmd = cmdMod(genISOCmd.cmd)
	}

	log.Infof("Using %s ISO generation command: %s", genISOCmd.cmdType, genISOCmd.String())

	if err = writeKSCfgs(cfgDir, ir, genISOCmd.String()); err != nil {
		api.HandleErr(w, req, tx.Tx, http.StatusInternalServerError, nil, fmt.Errorf("unable to create kickstarter files: %v", err))
		return
	}

	isoFilename := fmt.Sprintf("%s-%s.iso", ir.fqdn(), ir.OSVersionDir)
	// strings.ReplaceAll was added in Go 1.12
	isoFilename = strings.Replace(isoFilename, "/", "_", -1)

	w.Header().Set(rfc.ContentDisposition, fmt.Sprintf("attachment; filename=%q", isoFilename))
	w.Header().Set(rfc.ContentType, rfc.ApplicationOctetStream)

	if err = genISOCmd.stream(w); err != nil {
		api.HandleErr(w, req, tx.Tx, http.StatusInternalServerError, nil, fmt.Errorf("unable to generate ISO: %v", err))
		return
	}

	// Create changelog entry
	err = api.CreateChangeLogBuildMsg(
		api.ApiChange,
		api.Created,
		user,
		tx.Tx,
		"ISO",
		ir.fqdn(),
		map[string]interface{}{"OS": ir.OSVersionDir},
	)
	if err != nil {
		// At this point, it's not possible to modify the HTTP response.
		log.Errorf("error creating changelog entry for ISO creation: %v", err)
	}
}

// isoRequest represents the JSON object clients use to
// request an ISO be generated.
type isoRequest struct {
	OSVersionDir  string          `json:"osversionDir"`
	HostName      string          `json:"hostName"`
	DomainName    string          `json:"domainName"`
	RootPass      string          `json:"rootPass"`
	DHCP          boolStr         `json:"dhcp"`
	IPAddr        net.IP          `json:"ipAddress"`
	IPNetmask     net.IP          `json:"ipNetmask"`
	IPGateway     net.IP          `json:"ipGateway"`
	IP6Address    string          `json:"ip6Address"`
	IP6Gateway    net.IP          `json:"ip6Gateway"`
	InterfaceName string          `json:"interfaceName"`
	InterfaceMTU  util.JSONIntStr `json:"interfaceMtu"`
	Disk          string          `json:"disk"`
	MgmtIPAddress net.IP          `json:"mgmtIpAddress"`
	MgmtIPNetmask net.IP          `json:"mgmtIpNetmask"`
	MgmtIPGateway net.IP          `json:"mgmtIpGateway"`
	MgmtInterface string          `json:"mgmtInterface"`
}

func (i *isoRequest) fqdn() string {
	fqdn := i.HostName
	if i.DomainName != "" {
		fqdn += "." + i.DomainName
	}
	return fqdn
}

// validate returns an empty slice if the isoRequest is valid. Otherwise,
// it returns a slice of error messages.
func (i *isoRequest) Validate(tx *sql.Tx) error {
	errs := []error{}
	addErr := func(msg string) { errs = append(errs, errors.New(msg)) }

	if i.OSVersionDir == "" {
		addErr("osversionDir is required")
	}
	if i.HostName == "" {
		addErr("hostName is required")
	}
	if i.DomainName == "" {
		addErr("domainName is required")
	}
	if i.RootPass == "" {
		addErr("rootPass is required")
	}
	if !i.DHCP.isSet {
		addErr("dhcp is required")
	}
	if i.InterfaceMTU == 0 {
		addErr("interfaceMtu is required")
	}
	if i.Disk == "" {
		addErr("disk is required")
	}
	if len(i.MgmtIPAddress) > 0 {
		if i.MgmtInterface == "" {
			addErr("mgmtInterface is required when mgmtIpAddress is provided")
		}
		if len(i.MgmtIPGateway) == 0 {
			addErr("mgmtIpGateway is required when mgmtIpAddress is provided")
		}
	}
	if v, ok := i.DHCP.val(); ok && !v {
		if len(i.IPAddr) == 0 {
			addErr("ipAddress is required if DHCP is no")
		}
		if len(i.IPNetmask) == 0 {
			addErr("ipNetmask is required if DHCP is no")
		}
		if len(i.IPGateway) == 0 {
			addErr("ipGateway is required if DHCP is no")
		}
	}

	if i.IP6Address != "" {
		if ipv6, _, err := net.ParseCIDR(i.IP6Address); err != nil {
			if ipv6 = net.ParseIP(i.IP6Address); len(ipv6) != 16 || ipv6.To4() != nil {
				addErr("ip6Address must be a valid IPv6 address (with optional CIDR prefix)")
			}
		} else if len(ipv6) != 16 || ipv6.To4() != nil {
			addErr("ip6Address must be a valid IPv6 address (with optional CIDR prefix)")
		}
	}

	return util.JoinErrs(errs)
}

// validateOSDir ensures that the OSDir value corresponds to a
// valid directory, as determined by the osversions.json config
// file. Since this directory is acted upon during ISO generation,
// it's important that it be valid.
func (i *isoRequest) validateOSDir(tx *sqlx.Tx) (bool, error) {
	validOSVersions, err := getOSVersions(tx)
	if err != nil {
		return false, fmt.Errorf("unable to read osversions configuration: %v", err)
	}

	var isValid bool
	for _, validOSDir := range validOSVersions {
		if i.OSVersionDir == validOSDir {
			isValid = true
			break
		}
	}

	return isValid, nil
}

// boolStr is used to decode boolean strings (e.g. "yes") as
// part of a JSON response. Part of the /isos JSON request
// generated by TrafficPortal uses this format.
// If an unrecognize or empty string is given, then
// the 'val' and 'isSet' fields will be false. Otherwise,
// 'isSet' will be true.
type boolStr struct {
	isSet bool // false if UnmarshalText is given an unrecognized value
	v     bool
}

// UnmarshalText decodes strings representing boolean values.
// It nevers returns an error to allow for all validation errors
// to be grouped together.
func (b *boolStr) UnmarshalText(text []byte) error {
	switch strings.ToLower(string(text)) {
	case "yes", "true", "1":
		b.v = true
		b.isSet = true
	case "no", "false", "0":
		b.v = false
		b.isSet = true
	}
	return nil
}

// val returns the boolean value and whether
// the value was set or not.
func (b *boolStr) val() (value, ok bool) {
	return b.v, b.isSet
}
