前言#
在構建 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 等手段來定位二進制內容更加的高效、易維護。
在此基礎上,其實還可以實現更多的事情(依賴驗證、安裝多個文件等),歡迎嘗試