前言#
在构建 Linux/Unix 系安装包时,除了打包成标准的适用于各种发行版的软件包以外,我们更多的可能希望可以提供一个 shell 脚本进行程序的安装,将安装步骤简单收敛为两步:下载脚本 + 运行脚本。
通常,这种大多数的安装脚本都是再次从互联网上下载所需资源的,这样可以最小化脚本的体积并保证安装的始终是最新版本,但是这同样导致了下载到的「安装包」本质上是个「安装器」,无法离线安装。
本文将介绍一种已经在生产环境验证过的方案,来动态在安装包中嵌入网址。
受限于一些原因,本文更多的从原理层面进行讲解,暂无法提供完整的代码解决方案,敬请谅解
另外,以下代码均为根据原理为本文撰写,虽然原理已经经过生产验证但所使用的代码并未经过严格的生产验证,如有 bug 烦请告知
脚本构成#
整个脚本由 head + embed-bin 两部份构成;embed-bin 是不加改动的将我们的程序进行嵌入的,而 head 是一个动态生成的脚本,用于从当前脚本中提取 embed-bin 并执行。
head 脚本随是动态生成,但为了维护的简单,这里采用模板的形式
#!/bin/sh
#
# NAME: {{ .Name }}
# PLATFORM: { .Platform }}
# DIGEST: {{ .MD5 }}, @LINES@
THIS_DIR=$(DIRNAME=$(dirname "$0"); cd "$DIRNAME"; pwd)
THIS_FILE=$(basename "$0")
THIS_PATH="$THIS_DIR/$THIS_FILE"
EXTRACT={{ if .AutoExtract }}1{{ else }}0{{ end }}
FORCE_EXTRACT=0
PREFIX={{ .DefaultPrefix }}
EXECUTE={{ if .AutoExecute }}1{{ else }}0{{ end }}
{{- end }}
USAGE="{{ .Opts.Usage }}"
while getopts ":h{{ .Opts.FlagNames }}" flag; do
case "$flag" in
h)
printf "%s" "$USAGE"
exit 2
;;
{{- range .Opts.All }}
{{ .Name }})
{{ range .Action.DoIfSet }}{{ . }}
{{ end }};;{{ end }}
*)
printf "ERROR: did not recognize option '%s', please try -h\\n" "$1"
exit 1
;;
esac
done
# Verify MD5
printf "%s\\n" "Verifying file..."
MD5=$(tail -n +@LINES@ "$THIS_PATH" | md5sum)
if ! echo "$MD5" | grep {{ .MD5 }} >/dev/null; then
printf "ERROR: md5sum mismatch of tar archive\\n" >&2
printf "expected: {{ .MD5 }}\\n" >&2
printf " got: %s\\n" "$MD5" >&2
exit 3
fi
{{ if .Archive -}}
if [ -z "$PREFIX" ]; then
PREFIX=$(mktemp -d -p $(pwd))
fi
if [ "$EXTRACT" = "1" ]; then
if [ "$FORCE_EXTRACT" = "1" ] || [ ! -f "$PREFIX/.extract-done" ] || [ "$(cat "$PREFIX/.extract-done")" != "{{ .MD5}}" ]; then
printf "Extracting archive to %s ...\\n" "$PREFIX"
{
dd if="$THIS_PATH" bs=1 skip=@ARCHIVE_FIRST_OFFSET@ count=@ARCHIVE_FIRST_BYTES@ 2>/dev/null
dd if="$THIS_PATH" bs=@BLOCK_SIZE@ skip=@ARCHIVE_BLOCK_OFFSET@ count=@ARCHIVE_BLOCKS_COUNT@ 2>/dev/null
dd if="$THIS_PATH" bs=1 skip=@ARCHIVE_LAST_OFFSET@ count=@ARCHIVE_LAST_BYTES@ 2>/dev/null
} | tar zxf - -C "$PREFIX"
echo -n {{ .MD5 }} > "$PREFIX/.extract-done"
else
printf "Archive has already been extracted to %s\\n" "$PREFIX"
fi
fi
if [ "$EXECUTE" = "1" ]; then
echo "Run Command:" {{ .Command }}
cd "$PREFIX" && {{ .Command }}
fi
{{- end }}
exit 0
## --- DATA --- ##
这个脚本模板存在着两种类型的变量:{{ XX }}
与 %XX%
,其中主要的区别在于整个模板渲染要分成两步:首先渲染所有的 {{ XX }}
变量,然后再渲染剩余的 %XX%
变量;渲染前者时无特殊要求,而渲染后者时需要保证变量的渲染前后文本的长度与行数不变。
这个脚本会将 embed-bin 作为压缩包进行解压,这主要是因为我们内部使用时相关数据可能很大(数百兆乃至上 GB),如果你只需要一个小的脚本可以移除压缩有关的代码。
另外,这个脚本会在执行前进行一次 MD5 校验,这主要是为了防止一些情况下脚本下载不完全导致的。但是因为本身 embed-bin 就是压缩包了,因此可以删除校验有关的代码来加快安装速度(我们内部保留的原因一方面是因为我们 embed 的内容不止压缩包甚至不止一个文件,另一方面就是为了给出更好的错误提示)。
这个脚本也提供了参数传递的能力和部分默认值的指定,这是因为在某些情况下相关步骤可能异常而全量执行所有步骤较为耗时,在实际使用中你可根据实际需要删改脚本参数。
脚本的参数由模板渲染引擎给出,这主要是为了可维护性,如果你更希望在脚本中撰写相关的内容则可以修改相关部分
渲染脚本#
话不多说,直接上代码
//go:embed "header.sh.tmpl"
var headerTemplate string
type headerOptions struct {
Name string
MD5 string
Opts *Opts
*ArchiveOptions
}
type ArchiveOptions struct {
DefaultPrefix string
AutoExtract bool
AutoExecute bool
Command string // 使用 $PREFIX 引用 prefix
Filename string // 供 builder 使用,不会打入最终文件
}
func (o *ArchiveOptions) QuotedCommand() string {
return shells.Quote(o.Command)
}
func renderHeaders(o *headerOptions) ([]byte, error) {
t := template.New("")
tt, err := t.Parse(headerTemplate)
if err != nil {
return nil, ee.Wrap(err, "invalid template")
}
b := bytes.Buffer{}
err = tt.Execute(&b, o)
if err != nil {
return nil, err
}
return b.Bytes(), nil
}
func getHeaders(o *headerOptions) ([]byte, error) {
tmpl, err := renderHeaders(o)
if err != nil {
return nil, err
}
lines := bytes.Count(tmpl, []byte("\n")) + 1
tmpl = bytes.ReplaceAll(tmpl, []byte("@LINES@"), []byte(strconv.Itoa(lines)))
replaceAndFillSpace(tmpl, "@BLOCK_SIZE@", blockSize)
return tmpl, nil
}
func replaceAndFillSpace(data []byte, old string, new int64) {
oldBytes := []byte(old)
newString := strconv.FormatInt(new, 10)
newWithExtraSpace := append([]byte(newString), bytes.Repeat([]byte{' '}, len(old)-len(newString))...)
// assert len(old) == len(newWithExtraSpace)
// Apply replacements to buffer.
start := 0
for {
i := bytes.Index(data[start:], oldBytes)
if i == -1 {
return // stop
}
start += i
start += copy(data[start:], newWithExtraSpace)
}
}
type Opts struct {
All []*Opt
}
func (opts *Opts) FlagNames() string {
b := strings.Builder{}
for _, opt := range opts.All {
b.WriteString(opt.Name)
if len(opt.Arg) != 0 {
b.WriteString(":")
}
}
return b.String()
}
func (opts *Opts) Usage() string {
b := strings.Builder{}
b.WriteString("Usage: $0 [options]\n\n")
all := make([][2]string, 0, 1+len(opts.All))
nameLen := 2
all = append(all, [2]string{"-h", "Print this help message and exit"})
for _, opt := range opts.All {
bb := strings.Builder{}
bb.WriteString("-")
bb.WriteString(opt.Name)
if opt.Arg != "" {
bb.WriteString(" [")
bb.WriteString(opt.Arg)
bb.WriteString("]")
}
name := bb.String()
if len(name) > nameLen {
nameLen = len(name)
}
all = append(all, [2]string{name, opt.Help})
}
for _, a := range all {
b.WriteString(a[0])
b.WriteString(strings.Repeat(" ", nameLen-len(a[0])))
b.WriteString("\t")
b.WriteString(a[1])
b.WriteString("\n")
}
return b.String()
}
type Opt struct {
Name string
Arg string
Help string
Action OptAction
}
type OptAction interface {
DoIfSet() []string
}
type DoAndExitAction struct {
Do []string
ExitCode int
}
func (a *DoAndExitAction) DoIfSet() []string {
r := append([]string{}, a.Do...)
r = append(r, "exit "+strconv.Itoa(a.ExitCode))
return r
}
type DoAndContinueAction struct {
Do []string
}
func (a *DoAndContinueAction) DoIfSet() []string {
return a.Do
}
func SimpleSetEnvAction(envName string, envValue interface{}) *DoAndContinueAction {
return &DoAndContinueAction{
Do: []string{fmt.Sprintf("%s=%v", envName, envValue)},
}
}
type Builder struct {
Name string
ArchiveOptions *ArchiveOptions
}
func openAndWrite(filename string, w io.Writer) (int64, error) {
f, err := os.Open(filename)
if err != nil {
return 0, err
}
defer f.Close()
return io.Copy(w, f)
}
func fillAndSetHeader(prefix, filename string, f io.Writer, headers []byte, offset int64) (int64, error) {
fileLength, err := openAndWrite(filename, f)
if err != nil {
return 0, ee.Wrap(err, "cannot append data for "+prefix)
}
firstOffset := offset
firstBytes := blockSize - (firstOffset % blockSize)
replaceAndFillSpace(headers, fmt.Sprintf("@%s_FIRST_OFFSET@", prefix), firstOffset)
replaceAndFillSpace(headers, fmt.Sprintf("@%s_FIRST_BYTES@", prefix), firstBytes)
copy2Start := firstOffset + firstBytes
copy2Skip := copy2Start / blockSize
copy2Blocks := (fileLength - copy2Start + firstOffset) / blockSize
replaceAndFillSpace(headers, fmt.Sprintf("@%s_BLOCK_OFFSET@", prefix), copy2Skip)
replaceAndFillSpace(headers, fmt.Sprintf("@%s_BLOCKS_COUNT@", prefix), copy2Blocks)
copy3Start := (copy2Skip + copy2Blocks) * blockSize
copy3Size := fileLength - firstBytes - (copy2Blocks * blockSize)
replaceAndFillSpace(headers, fmt.Sprintf("@%s_LAST_OFFSET@", prefix), copy3Start)
replaceAndFillSpace(headers, fmt.Sprintf("@%s_LAST_BYTES@", prefix), copy3Size)
return fileLength, nil
}
func (b *Builder) Build(saveTo string) error {
header := &headerOptions{
Name: b.Name,
ArchiveOptions: b.ArchiveOptions,
Opts: &Opts{},
}
fileMD5 := md5.New()
var dataSize int64
if header.ArchiveOptions != nil {
if header.ArchiveOptions.AutoExtract {
header.Opts.All = append(header.Opts.All, &Opt{
Name: "E",
Help: "Do not extract archive",
Action: SimpleSetEnvAction("EXTRACT", 0),
})
} else {
header.Opts.All = append(header.Opts.All, &Opt{
Name: "e",
Help: "Also extract archive",
Action: SimpleSetEnvAction("EXTRACT", 1),
})
}
header.Opts.All = append(header.Opts.All, &Opt{
Name: "f",
Help: "Force extract archive",
Action: SimpleSetEnvAction("FORCE_EXTRACT", 1),
})
prefixOpt := &Opt{
Name: "d",
Arg: "DIR",
Help: "Extract to directory",
Action: &DoAndContinueAction{
Do: []string{`PREFIX="${OPTARG}"`},
},
}
if header.ArchiveOptions.DefaultPrefix != "" {
prefixOpt.Help += fmt.Sprintf(" (default: %s)", header.ArchiveOptions.DefaultPrefix)
}
header.Opts.All = append(header.Opts.All, prefixOpt)
if header.ArchiveOptions.Command != "" {
if header.ArchiveOptions.AutoExecute {
header.Opts.All = append(header.Opts.All, &Opt{
Name: "X",
Help: "Do not execute command",
Action: SimpleSetEnvAction("EXECUTE", 0),
})
} else {
header.Opts.All = append(header.Opts.All, &Opt{
Name: "x",
Help: "Also execute the command",
Action: SimpleSetEnvAction("EXECUTE", 1),
})
}
}
n, err := openAndWrite(header.ArchiveOptions.Filename, fileMD5)
if err != nil {
return ee.Wrap(err, "failed to read archive file to get md5")
}
dataSize += n
}
_ = dataSize
header.MD5 = hex.EncodeToString(fileMD5.Sum(nil))
headers, err := getHeaders(header)
if err != nil {
return ee.Wrap(err, "failed to get headers")
}
f, err := os.OpenFile(saveTo, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return ee.Wrap(err, "failed to write file")
}
defer f.Close()
// write header
headersLen, err := f.Write(headers)
if err != nil {
return ee.Wrap(err, "failed to write headers")
}
currentOffset := int64(headersLen)
// embed archive
if header.ArchiveOptions != nil {
n, err := fillAndSetHeader("ARCHIVE", header.ArchiveOptions.Filename, f, headers, currentOffset)
if err != nil {
return ee.Wrap(err, "failed to embed installer")
}
currentOffset += n
}
_ = currentOffset
// rewrite headers
_, err = f.Seek(0, 0)
if err != nil {
return ee.Wrap(err, "failed to seek file")
}
newHeadersLen, err := f.Write(headers)
if err != nil {
return ee.Wrap(err, "failed to rewrite headers")
}
if headersLen != newHeadersLen {
return ee.New("headers unexpected change after rewrite")
}
return nil
}
使用则是
b := &Builder{
Name: name,
ArchiveOptions: &binbundler.ArchiveOptions{
DefaultPrefix: "/path/to/extract",
AutoExtract: true,
AutoExecute: true,
Command: "bash $PREFIX/install.sh", # 安装命令,简单可直接执行,复杂可使用一个额外的脚本
Filename: "/path/to/embed",
},
}
err = b.Build("/path/to/script-save-to.sh")
在整个脚本中,动态插入了相关模板变量,并计算了相关 offset
后记#
本文更多的只是提供一种思路(利用 dd 来解压、动态生成 opt 来控制执行过程),相比于网上更多的利用 grep 等手段来定位二进制内容更加的高效、易维护。
在此基础上,其实还可以实现更多的事情(依赖验证、安装多个文件等),欢迎尝试