blob: 9772220517e41a910de0b7367271335778706451 [file] [log] [blame]
// 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/lib/go-log"
"github.com/apache/trafficcontrol/lib/go-rfc"
"github.com/apache/trafficcontrol/lib/go-util"
"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
"github.com/apache/trafficcontrol/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
}