Compare commits

...

60 Commits

Author SHA1 Message Date
Adphi f711f8919d fix vhd format support
closes  #47

Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2024-05-08 17:06:20 +02:00
Spencer von der Ohe b27add5767 Fix typo in README.md
Signed-off-by: Spencer von der Ohe <s.vonderohe40@gmail.com>
2023-11-17 15:57:45 +01:00
Adphi 1934915ae8
templates: clean package manager cache
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-09-14 12:42:52 +02:00
Adphi d54b3f9a2c
arm64 support with grub-efi
* build / convert: add `--platform` flag to support linux/amd64 &
linux/arm64
* build: add `--pull` flag
* run/hetzner: add `--type` flag to select server type
* run/hetzner: add `--location` flag to select server location

Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-09-13 17:42:39 +02:00
Adphi f8fc729486
grub and grub-efi not supported for CentOS
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-09-13 13:42:00 +02:00
Adphi a41bbdb745
add grub-efi support
* tests: increase timeout
* ci: split e2e tests

Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-09-13 11:06:26 +02:00
Adphi d4c3476031
docs: update cli and README
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-09-11 19:23:48 +02:00
Adphi fb3ee62962
add fat32 boot partition support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-09-11 19:23:47 +02:00
Adphi 384a4e436c
docs: update cli and README
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-09-11 18:13:35 +02:00
Adphi a22bf9caf1
fix debian stretch support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-09-11 18:13:34 +02:00
Adphi 4e533b8044
add grub support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-09-11 18:13:34 +02:00
Adphi a003e176f5
chore: bootloader abtraction
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-09-11 13:44:12 +02:00
Adphi ec33a7ad74
actions: move ubuntu 18 runners to latest
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-06-04 15:16:45 +02:00
Adphi 2970af4873
update command line docs
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-06-04 14:47:40 +02:00
Adphi 9abb66ad1d
run/hetzner: add split-boot support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-04-23 17:30:45 +02:00
Adphi 6ef6df535d
deps: update all
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-04-23 14:24:05 +02:00
dependabot[bot] f0798b3f3a build(deps): bump golang.org/x/net from 0.1.0 to 0.7.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.1.0 to 0.7.0.
- [Release notes](https://github.com/golang/net/releases)
- [Commits](https://github.com/golang/net/compare/v0.1.0...v0.7.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-03 17:29:33 +01:00
Adphi 93ba19a1fa fix execution exit code on error
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi fb33b2a74e luks: do not support ubuntu < 20.04 and debian < 10
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi 8c36d42e06 luks: fix centos Dockerfile template
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi 709f24b5df actions: add end-to-end tests as release dependency
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi f7b4861b1d tests: fix permissions error
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi 0c3a736977 actions: move e2e test to a separated step
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi f75b0c7313 luks: fix ubuntu <22.04 support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi bfa5f0df1d save docker image to disk before flatten
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi be88bc29f5 add keep-cache option to preserve intermediate docker images
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi 4780228c95 luks: implements support for centos
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi 2f34e19636 luks: implements support for debian like distibutions
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
Adphi 3ec9bdfb01 luks: implements support for Alpine
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
dependabot[bot] cab7d8badd build(deps): bump golang.org/x/sys
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.0.0-20211216021012-1d35b9e2eb4e to 0.1.0.
- [Release notes](https://github.com/golang/sys/releases)
- [Commits](https://github.com/golang/sys/commits/v0.1.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-28 12:09:26 +01:00
Adphi fae73e71bc chore: upgrade go to 1.20
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-23 13:30:24 +01:00
Adphi 74aac3fdcb actions: add end-to-end tests
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-23 13:30:24 +01:00
Adphi f50f8d0f93 actions: build release snapshot only on main and tags
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-23 13:30:24 +01:00
Adphi 1970ac19e4 run/qemu: remove usb and device flags
refactor: move qemu to its own package
tests: implements end to end tests for the convert command with the following images: alpine:3.17, ubuntu:20.04, debian:11, centos:8

Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-23 13:30:24 +01:00
Adphi 532ee3f1a3 add split boot partiton support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-22 18:09:18 +01:00
Adphi 490f235c6d
docs: configure CNAME
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-17 00:39:14 +01:00
Adphi 466d6d40d3
deps: go mod tidy
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-15 11:29:40 +01:00
Adphi bf2687a211
docs: add container disk support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-15 11:06:52 +01:00
Adphi d652bf41f5
run: fix qemu-img convert path typo
build & convert: add kubevirt container disk support

Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-15 10:43:23 +01:00
Adphi 618b5bc861
use kpartx instead of partprobe (close #19)
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-15 10:36:05 +01:00
Adphi 8659907d62
fix Alpine 3.17 support (close #16)
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-15 10:20:37 +01:00
Neosb c66595115f kali linux 2022-09-19 09:28:14 +02:00
Adphi 6ac85912c1
docs: document dependencies and docker install method (close #10)
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-13 15:53:55 +02:00
Adphi d18e68b138
run in docker if not root or sudo (fix #5 #9 #11)
set user permissions on image if run with sudo or in docker
run/vbox & run/hetzner: run qemu-img in docker if not available in path

Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-13 15:02:38 +02:00
Adphi 43f2dd5452
Makefile: install: fix missing go generate
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 12:34:34 +02:00
Adphi 72413b0bac
docs: add homebrew install instructions
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 12:05:27 +02:00
Adphi fb5f21f1f3
actions: run tests only if go files or templates changed
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 11:47:11 +02:00
Adphi 8f1ae3a8a4
chore: remove sparsecat binary from repo
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 11:43:16 +02:00
Adphi a6163db5b8
actions: publish docs only on tag
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 11:23:50 +02:00
Adphi 9d2ceb8cba
docs: regenerate cli reference
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 11:21:45 +02:00
Adphi 3940cd8975
BREAKING CHANGE: remove root default password, configure it only if provided (close #7)
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 11:19:51 +02:00
Adphi 7ad6343e6f
goreleaser: add brew support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 10:50:50 +02:00
Adphi 2cd50ff38c
Makefile: fix completions command
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 10:13:41 +02:00
Adphi f855fe9c7a
Makefile: add completions generation
goreleaser: fix typo, add completions to release archive

Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 10:10:28 +02:00
Adphi f1557d104d
fix zsh completion not working with `source`
README.md: add shell completion installation instructions

Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 10:01:04 +02:00
Adphi 7f3b3a859d
Makefile: add install
README.md: improve install docs
goreleaser: add README.md to release tarball

Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 09:23:33 +02:00
Adphi 7718c533eb
add pgp public key
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-11 20:02:04 +02:00
Adphi 0208a2a134
chore: expose builder interface
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-11 20:01:37 +02:00
Adphi 238d9a51af
templates: do not set root password if empty
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-11 20:00:48 +02:00
Adphi bf88399b58
run/hetzner: rollback sparsecat using outside linux
Makefile: compute tag: ignore dirty state

Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-11 04:01:05 +02:00
67 changed files with 3218 additions and 2227 deletions

View File

@ -7,3 +7,5 @@ bin
dist
images
examples/build
e2e
**/*_test.go

View File

@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.18
go-version: "1.20"
- name: Set up QEMU dependency
uses: docker/setup-qemu-action@v1
@ -30,7 +30,7 @@ jobs:
uses: docker/setup-buildx-action@v1
- name: Setup dependencies
run: sudo apt update && sudo apt install -y util-linux udev parted e2fsprogs mount tar extlinux qemu-utils
run: sudo apt update && sudo apt install -y util-linux udev parted e2fsprogs mount tar extlinux qemu-utils qemu-system
- name: Share cache with other actions
uses: actions/cache@v2
@ -43,7 +43,100 @@ jobs:
${{ runner.os }}-tests-
- name: Run tests
run: make tests
run: git --no-pager diff --exit-code HEAD~1 HEAD **/**.go templates/ || make tests
templates-tests:
name: Test Templates
runs-on: ubuntu-latest
strategy:
matrix:
image:
- ubuntu
- debian
- kalilinux
- alpine
- centos
steps:
- name: Checkout
uses: actions/checkout@v3
with:
# fetching all tags is required for the Makefile to compute the right version
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: "1.20"
- name: Set up QEMU dependency
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Setup dependencies
run: sudo apt update && sudo apt install -y util-linux udev parted e2fsprogs mount tar extlinux qemu-utils qemu-system
- name: Share cache with other actions
uses: actions/cache@v2
with:
path: |
~/go/pkg/mod
/tmp/.buildx-cache
key: ${{ runner.os }}-tests-${{ github.sha }}
restore-keys: |
${{ runner.os }}-tests-
- name: Run tests
run: git --no-pager diff --exit-code HEAD~1 HEAD **/**.go templates/ || IMAGE=${{ matrix.image }} make test-templates
e2e-tests:
name: End to end Tests
runs-on: ubuntu-latest
strategy:
matrix:
image:
- alpine:3.17
- ubuntu:20.04
- ubuntu:22.04
- debian:10
- debian:11
- centos:8
steps:
- name: Checkout
uses: actions/checkout@v3
with:
# fetching all tags is required for the Makefile to compute the right version
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: "1.20"
- name: Set up QEMU dependency
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Setup dependencies
run: sudo apt update && sudo apt install -y util-linux udev parted e2fsprogs mount tar extlinux qemu-utils qemu-system ovmf
- name: Share cache with other actions
uses: actions/cache@v2
with:
path: |
~/go/pkg/mod
/tmp/.buildx-cache
key: ${{ runner.os }}-e2e-tests-${{ github.sha }}
restore-keys: |
${{ runner.os }}-tests-
- name: Run end-to-end tests
run: E2E_IMAGES=${{ matrix.image }} make e2e
docs-up-to-date:
name: Docs up to date
@ -59,7 +152,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.18
go-version: "1.20"
- name: Share cache with other actions
uses: actions/cache@v2
@ -88,7 +181,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.18
go-version: "1.20"
- name: Set up QEMU dependency
uses: docker/setup-qemu-action@v1
@ -119,6 +212,7 @@ jobs:
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v4
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main'
with:
gpg_private_key: ${{ secrets.GPG_KEY }}
passphrase: ${{ secrets.GPG_PASSWORD }}
@ -127,6 +221,7 @@ jobs:
run: make build-snapshot
- name: Release Snapshot
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ secrets.REPOSITORIES_ACCESS_TOKEN }}
GPG_PASSWORD: ${{ secrets.GPG_PASSWORD }}
@ -135,7 +230,7 @@ jobs:
build-image:
name: Build Docker Image
runs-on: ubuntu-18.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
@ -146,7 +241,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.18
go-version: "1.20"
- name: Set up QEMU dependency
uses: docker/setup-qemu-action@v1
@ -180,12 +275,14 @@ jobs:
release:
name: Release Binaries
runs-on: ubuntu-18.04
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/v')
needs:
- tests
- templates-tests
- docs-up-to-date
- build
- e2e-tests
steps:
- name: Checkout
@ -197,7 +294,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.18
go-version: "1.20"
- name: Share cache with other actions
uses: actions/cache@v2
@ -228,12 +325,13 @@ jobs:
release-image:
name: Release Docker Image
runs-on: ubuntu-18.04
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/v')
needs:
- tests
- docs-up-to-date
- build-image
- e2e-tests
steps:
- name: Checkout
@ -245,7 +343,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.18
go-version: "1.20"
- name: Set up QEMU dependency
uses: docker/setup-qemu-action@v1

View File

@ -1,9 +1,7 @@
name: Docs
on:
push:
branches:
- docs
- main
tags: [ "v*" ]
jobs:
deploy:
runs-on: ubuntu-latest

5
.gitignore vendored
View File

@ -4,12 +4,17 @@ scratch
*.qcow2
*.vmdk
*.vdi
.DS_Store
bin/
dist/
images
/d2vm
/examples/build
/examples/full/demo-magic
/examples/full/inside
.goreleaser.yaml
docs/build
docs-src
/completions
/cmd/d2vm/run/sparsecat-linux-*

View File

@ -15,6 +15,8 @@ project_name: d2vm
before:
hooks:
- go mod tidy
- go generate ./...
- make completions
builds:
- main: ./cmd/d2vm
env:
@ -37,6 +39,35 @@ snapshot:
name_template: "{{ .Env.VERSION }}"
release:
prerelease: auto
extra_files:
- glob: LICENCE
- glob: pgp.pub
archives:
- name_template: '{{ .ProjectName }}_{{ .Env.VERSION }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
files:
- LICENCE
- README.md
- completions/*
brews:
- name: d2vm
tap:
owner: linka-cloud
name: homebrew-tap
folder: Formula
homepage: https://github.com/linka-cloud/d2vm
description: Build Virtual Machine Image from Dockerfile or Docker image
license: Apache License 2.0
test: |
system "#{bin}/d2vm --version"
dependencies:
- name: go
type: optional
- name: git
install: |-
bin.install "d2vm"
bash_completion.install "completions/d2vm.bash" => "d2vm"
zsh_completion.install "completions/d2vm.zsh" => "_d2vm"
fish_completion.install "completions/d2vm.fish"
changelog:
sort: asc
filters:

View File

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM golang as builder
FROM golang:1.20 as builder
WORKDIR /d2vm
@ -33,11 +33,16 @@ RUN apt-get update && \
util-linux \
udev \
parted \
kpartx \
e2fsprogs \
dosfstools \
mount \
tar \
extlinux \
qemu-utils
"$([ "$(uname -m)" = "x86_64" ] && echo extlinux)" \
cryptsetup-bin \
qemu-utils && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/

View File

@ -16,7 +16,7 @@ MODULE = go.linka.cloud/d2vm
REPOSITORY = linkacloud
TAG = $(shell git describe --tags --exact-match 2> /dev/null)
TAG = $(shell git diff --quiet && git describe --tags --exact-match 2> /dev/null)
VERSION_SUFFIX = $(shell git diff --quiet || echo "-dev")
VERSION = $(shell git describe --tags --exact-match 2> /dev/null || echo "`git describe --tags $$(git rev-list --tags --max-count=1) 2> /dev/null || echo v0.0.0`-`git rev-parse --short HEAD`")$(VERSION_SUFFIX)
show-version:
@ -64,11 +64,19 @@ docker-run:
.PHONY: tests
tests:
@go generate ./...
@go list ./...| xargs go test -exec sudo -count=1 -timeout 20m -v
@go list .| xargs go test -exec sudo -count=1 -timeout 60m -v -skip TestConfig
.PHONY: test-templates
test-templates:
@go generate ./...
@go test -exec sudo -count=1 -timeout 60m -v -run TestConfig/$(IMAGE)
e2e: docker-build .build
@go test -v -exec sudo -count=1 -timeout 60m -ldflags "-X '$(MODULE).Version=$(VERSION)' -X '$(MODULE).BuildDate=$(shell date)'" ./e2e -args -images $(E2E_IMAGES)
docs-up-to-date:
@$(MAKE) cli-docs
@git diff --quiet -- docs ':(exclude)docs/content/reference/d2vm_run_qemu.md' || (git --no-pager diff -- docs ':(exclude)docs/content/reference/d2vm_run_qemu.md'; echo "Please regenerate the documentation with 'make docs'"; exit 1)
@git diff --quiet -- docs ':(exclude)docs/content/reference/d2vm_run_qemu.md' || (git --no-pager diff -- docs ':(exclude)docs/content/reference/d2vm_run_qemu.md'; echo "Please regenerate the documentation with 'make cli-docs'"; exit 1)
check-fmt:
@[ "$(gofmt -l $(find . -name '*.go') 2>&1)" = "" ]
@ -78,9 +86,13 @@ vet:
build-dev: docker-build .build
install: docker-build
@go generate ./...
@go install -ldflags "-s -w -X '$(MODULE).Version=$(VERSION)' -X '$(MODULE).BuildDate=$(shell date)'" ./cmd/d2vm
.build:
@go generate ./...
@go build -o d2vm -ldflags "-s -w -X '$(MODULE).Version=$(VERSION)' -X '$(MODULE).BuildDate=$(shell date)'" ./cmd/d2vm
@CGO_ENABLED=0 go build -o d2vm -ldflags "-s -w -X '$(MODULE).Version=$(VERSION)' -X '$(MODULE).BuildDate=$(shell date)'" ./cmd/d2vm
.PHONY: build-snapshot
build-snapshot: bin
@ -98,15 +110,23 @@ build: bin
release: bin
@VERSION=$(VERSION) IMAGE=$(DOCKER_IMAGE) goreleaser release --rm-dist --parallelism 8
.PHONY: completions
completions: .build
@rm -rf completions
@mkdir -p completions
@for shell in bash zsh fish powershell; do \
./d2vm completion $$shell > completions/d2vm.$$shell; \
done
.PHONY: examples
examples: build-dev
@mkdir -p examples/build
@for f in $$(find examples -type f -name '*Dockerfile' -maxdepth 1); do \
@for f in $$(find examples -maxdepth 1 -type f -name '*Dockerfile'); do \
echo "Building $$f"; \
./d2vm build -o examples/build/$$(basename $$f|cut -d'.' -f1).qcow2 -f $$f examples; \
./d2vm build -o examples/build/$$(basename $$f|cut -d'.' -f1).qcow2 -p root -f $$f examples --force; \
done
@echo "Building examples/full/Dockerfile"
@./d2vm build -o examples/build/full.qcow2 --build-arg=USER=adphi --build-arg=PASSWORD=adphi examples/full
@./d2vm build -o examples/build/full.qcow2 --build-arg=USER=adphi --build-arg=PASSWORD=adphi examples/full --force
cli-docs: .build
@rm -rf $(CLI_REFERENCE_PATH)

139
README.md
View File

@ -19,14 +19,19 @@ Many thanks to him.
**Only building Linux Virtual Machine images is supported.**
**Starting from v0.1.0, d2vm automatically run build and convert commands inside Docker when not running on linux**.
Starting from v0.1.0, **d2vm** automatically run build and convert commands inside Docker when not running on linux
or when running without *root* privileges.
*Note: windows should be working, but is totally untested.*
## Supported VM Linux distributions:
Working and tested:
- [x] Ubuntu (18.04+)
Luks support is available only on Ubuntu 20.04+
- [x] Debian (stretch+)
Luks support is available only on Debian buster+
- [x] Alpine
- [x] CentOS (8+)
@ -37,15 +42,71 @@ Unsupported:
The program uses the `/etc/os-release` file to discover the Linux distribution and install the Kernel,
if the file is missing, the build cannot succeed.
Obviously, **Distroless** images are not supported.
Obviously, **Distroless** images are not supported.
## Prerequisites
### osx
- [Docker](https://docs.docker.com/get-docker/)
- [QEMU](https://www.qemu.org/download/#macos) (optional)
- [VirtualBox](https://www.virtualbox.org/wiki/Downloads) (optional)
### Linux
- [Docker](https://docs.docker.com/get-docker/)
- util-linux
- udev
- parted
- e2fsprogs
- dosfstools (when using fat32)
- mount
- tar
- extlinux (when using syslinux)
- qemu-utils
- cryptsetup (when using LUKS)
- [QEMU](https://www.qemu.org/download/#linux) (optional)
- [VirtualBox](https://www.virtualbox.org/wiki/Linux_Downloads) (optional)
## Getting started
### Install from release
### Install
Download the latest release for your platform from the [release page](https://github.com/linka-cloud/d2vm/releases/latest)
#### With Docker
### Install from source
*Note: this will only work if both the source context (and Dockerfile) and the output directory are somewhere inside
the directory where you run the command.*
```bash
docker pull linkacloud/d2vm:latest
alias d2vm="docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock --privileged -v \$PWD:/d2vm -w /d2vm linkacloud/d2vm:latest"
```
```bash
which d2vm
d2vm: aliased to docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock --privileged -v $PWD:/d2vm -w /d2vm linkacloud/d2vm:latest
```
#### With Homebrew
```bash
brew install linka-cloud/tap/d2vm
```
#### From release
Download the latest release for your platform from the [release page](https://github.com/linka-cloud/d2vm/releases/latest).
Extract the tarball, then move the extracted *d2vm* binary to somewhere in your `$PATH` (`/usr/local/bin` for most users).
```bash
VERSION=$(git ls-remote --tags https://github.com/linka-cloud/d2vm |cut -d'/' -f 3|tail -n 1)
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$([ "$(uname -m)" = "x86_64" ] && echo "amd64" || echo "arm64")
curl -sL "https://github.com/linka-cloud/d2vm/releases/download/${VERSION}/d2vm_${VERSION}_${OS}_${ARCH}.tar.gz" | tar -xvz d2vm
sudo mv d2vm /usr/local/bin/
```
#### From source
Clone the git repository:
@ -56,9 +117,34 @@ git clone https://github.com/linka-cloud/d2vm && cd d2vm
Install using the *make*, *docker* and the Go tool chain:
```bash
make build-dev && sudo cp d2vm /usr/local/bin/
make install
```
The *d2vm* binary is installed in the `$GOBIN` directory.
```bash
which d2vm
/go/bin/d2vm
```
### Generate shell completion
The *d2vm* program supports shell completion for *bash*, *zsh* and *fish*.
It can be enabled by running the following command:
```bash
source <(d2vm completion $(basename $SHELL))
```
Or you can install the completion file in the shell completion directory by following the instructions:
```bash
d2vm completion $(basename $SHELL) --help
```
### Converting an existing Docker Image to VM image:
```bash
@ -72,20 +158,28 @@ Usage:
Flags:
--append-to-cmdline string Extra kernel cmdline arguments to append to the generated one
-f, --force Override output qcow2 image
--boot-fs string Filesystem to use for the boot partition, ext4 or fat32
--boot-size uint Size of the boot partition in MB (default 100)
--bootloader string Bootloader to use: syslinux, grub, grub-bios, grub-efi, defaults to syslinux on amd64 and grub-efi on arm64
--force Override output qcow2 image
-h, --help help for convert
--keep-cache Keep the images after the build
--luks-password string Password to use for the LUKS encrypted root partition. If not set, the root partition will not be encrypted
--network-manager string Network manager to use for the image: none, netplan, ifupdown
-o, --output string The output image, the extension determine the image format, raw will be used if none. Supported formats: qcow2 qed raw vdi vhd vmdk (default "disk0.qcow2")
-p, --password string The Root user password (default "root")
-p, --password string Optional root user password
--platform string Platform to use for the container disk image, linux/arm64 and linux/arm64 are supported (default "linux/amd64")
--pull Always pull docker image
--push Push the container disk image to the registry
--raw Just convert the container to virtual machine image without installing anything more
-s, --size string The output image size (default "10G")
--split-boot Split the boot partition from the root partition
-t, --tag string Container disk Docker image tag
Global Flags:
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```
Create an image based on the **ubuntu** official image:
@ -206,8 +300,6 @@ RUN apt update && apt install -y openssh-server && \
```
When building the vm image, *d2vm* will create a root password, so there is no need to configure it now.
Build the vm image:
The *build* command take most of its flags and arguments from the *docker build* command.
@ -224,18 +316,28 @@ Usage:
Flags:
--append-to-cmdline string Extra kernel cmdline arguments to append to the generated one
--boot-fs string Filesystem to use for the boot partition, ext4 or fat32
--boot-size uint Size of the boot partition in MB (default 100)
--bootloader string Bootloader to use: syslinux, grub, grub-bios, grub-efi, defaults to syslinux on amd64 and grub-efi on arm64
--build-arg stringArray Set build-time variables
-f, --file string Name of the Dockerfile
--force Override output image
--force Override output qcow2 image
-h, --help help for build
--keep-cache Keep the images after the build
--luks-password string Password to use for the LUKS encrypted root partition. If not set, the root partition will not be encrypted
--network-manager string Network manager to use for the image: none, netplan, ifupdown
-o, --output string The output image, the extension determine the image format, raw will be used if none. Supported formats: qcow2 qed raw vdi vhd vmdk (default "disk0.qcow2")
-p, --password string Root user password (default "root")
-p, --password string Optional root user password
--platform string Platform to use for the container disk image, linux/arm64 and linux/arm64 are supported (default "linux/amd64")
--pull Always pull docker image
--push Push the container disk image to the registry
--raw Just convert the container to virtual machine image without installing anything more
-s, --size string The output image size (default "10G")
--split-boot Split the boot partition from the root partition
-t, --tag string Container disk Docker image tag
Global Flags:
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```
@ -250,6 +352,13 @@ Or if you want to create a VirtualBox image:
sudo d2vm build -p MyP4Ssw0rd -f ubuntu.Dockerfile -o ubuntu.vdi .
```
### KubeVirt Container Disk Images
Using the `--tag` flag with the `build` and `convert` commands, you can create a
[Container Disk Image](https://kubevirt.io/user-guide/virtual_machines/disks_and_volumes/#containerdisk) for [KubeVirt](https://kubevirt.io/).
The `--push` flag will push the image to the registry.
### Complete example
A complete example setting up a ZSH workstation is available in the [examples/full](examples/full/README.md) directory.

43
bootloader.go Normal file
View File

@ -0,0 +1,43 @@
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"context"
"fmt"
)
var bootloaderProviders = map[string]BootloaderProvider{}
func RegisterBootloaderProvider(provider BootloaderProvider) {
bootloaderProviders[provider.Name()] = provider
}
func BootloaderByName(name string) (BootloaderProvider, error) {
if p, ok := bootloaderProviders[name]; ok {
return p, nil
}
return nil, fmt.Errorf("bootloader provider %s not found", name)
}
type BootloaderProvider interface {
New(c Config, r OSRelease, arch string) (Bootloader, error)
Name() string
}
type Bootloader interface {
Validate(fs BootFS) error
Setup(ctx context.Context, dev, root, cmdline string) error
}

View File

@ -23,6 +23,7 @@ import (
"strings"
"github.com/c2h5oh/datasize"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"go.uber.org/multierr"
@ -40,73 +41,20 @@ ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
ff02::3 ip6-allhosts
`
syslinuxCfgUbuntu = `DEFAULT linux
SAY Now booting the kernel from SYSLINUX...
LABEL linux
KERNEL /boot/vmlinuz
APPEND ro root=UUID=%s initrd=/boot/initrd.img net.ifnames=0 console=tty0 console=ttyS0,115200n8 %s
`
syslinuxCfgDebian = `DEFAULT linux
SAY Now booting the kernel from SYSLINUX...
LABEL linux
KERNEL /vmlinuz
APPEND ro root=UUID=%s initrd=/initrd.img net.ifnames=0 console=tty0 console=ttyS0,115200n8 %s
`
syslinuxCfgAlpine = `DEFAULT linux
SAY Now booting the kernel from SYSLINUX...
LABEL linux
KERNEL /boot/vmlinuz-virt
APPEND ro root=UUID=%s rootfstype=ext4 initrd=/boot/initramfs-virt console=ttyS0,115200 %s
`
syslinuxCfgCentOS = `DEFAULT linux
SAY Now booting the kernel from SYSLINUX...
LABEL linux
KERNEL /boot/vmlinuz
APPEND ro root=UUID=%s initrd=/boot/initrd.img net.ifnames=0 console=tty0 console=ttyS0,115200n8 %s
`
)
var (
formats = []string{"qcow2", "qed", "raw", "vdi", "vhd", "vmdk"}
mbrPaths = []string{
// debian path
"/usr/lib/syslinux/mbr/mbr.bin",
// ubuntu path
"/usr/lib/EXTLINUX/mbr.bin",
// alpine path
"/usr/share/syslinux/mbr.bin",
// centos path
"/usr/share/syslinux/mbr.bin",
// archlinux path
"/usr/lib/syslinux/bios/mbr.bin",
}
)
const (
perm os.FileMode = 0644
)
func sysconfig(osRelease OSRelease) (string, error) {
switch osRelease.ID {
case ReleaseUbuntu:
if osRelease.VersionID < "20.04" {
return syslinuxCfgDebian, nil
}
return syslinuxCfgUbuntu, nil
case ReleaseDebian:
return syslinuxCfgDebian, nil
case ReleaseAlpine:
return syslinuxCfgAlpine, nil
case ReleaseCentOS:
return syslinuxCfgCentOS, nil
default:
return "", fmt.Errorf("%s: distribution not supported", osRelease.ID)
}
var formats = []string{"qcow2", "qed", "raw", "vdi", "vhd", "vhd", "vhdx", "vmdk"}
type Builder interface {
Build(ctx context.Context) (err error)
Close() error
}
type builder struct {
osRelease OSRelease
osRelease OSRelease
config Config
bootloader Bootloader
src string
img *image
@ -114,20 +62,46 @@ type builder struct {
diskOut string
format string
size int64
size uint64
mntPoint string
mbrPath string
splitBoot bool
bootSize uint64
bootFS BootFS
loDevice string
bootPart string
rootPart string
cryptPart string
cryptRoot string
mappedCryptRoot string
bootUUID string
rootUUID string
cryptUUID string
luksPassword string
loDevice string
loPart string
diskUUD string
cmdLineExtra string
arch string
}
func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size int64, osRelease OSRelease, format string, cmdLineExtra string) (*builder, error) {
if err := checkDependencies(); err != nil {
return nil, err
func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size uint64, osRelease OSRelease, format string, cmdLineExtra string, splitBoot bool, bootFS BootFS, bootSize uint64, luksPassword string, bootLoader string, platform string) (Builder, error) {
var arch string
switch platform {
case "linux/amd64":
arch = "x86_64"
case "linux/arm64", "linux/aarch64":
arch = "arm64"
default:
return nil, fmt.Errorf("unexpected platform: %s, supported platforms: linux/amd64, linux/arm64", platform)
}
if luksPassword != "" {
if !splitBoot {
return nil, fmt.Errorf("luks encryption requires split boot")
}
if !osRelease.SupportsLUKS() {
return nil, fmt.Errorf("luks encryption not supported on %s %s", osRelease.ID, osRelease.VersionID)
}
}
f := strings.ToLower(format)
valid := false
@ -139,19 +113,55 @@ func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size int64, o
if !valid {
return nil, fmt.Errorf("invalid format: %s valid formats are: %s", f, strings.Join(formats, " "))
}
if f == "vhd" {
f = "vpc"
}
mbrBin := ""
for _, v := range mbrPaths {
if _, err := os.Stat(v); err == nil {
mbrBin = v
break
}
if splitBoot && bootSize < 50 {
return nil, fmt.Errorf("boot partition size must be at least 50MiB")
}
if mbrBin == "" {
return nil, fmt.Errorf("unable to find syslinux's mbr.bin path")
if splitBoot && bootSize >= size {
return nil, fmt.Errorf("boot partition size must be less than the disk size")
}
if bootLoader == "" {
bootLoader = "syslinux"
}
config, err := osRelease.Config()
if err != nil {
return nil, err
}
if splitBoot {
config.Kernel = strings.TrimPrefix(config.Kernel, "/boot")
config.Initrd = strings.TrimPrefix(config.Initrd, "/boot")
}
if bootFS == "" {
bootFS = BootFSExt4
}
if err := bootFS.Validate(); err != nil {
return nil, err
}
blp, err := BootloaderByName(bootLoader)
if err != nil {
return nil, err
}
bl, err := blp.New(config, osRelease, arch)
if err != nil {
return nil, err
}
if err := bl.Validate(bootFS); err != nil {
return nil, err
}
if size == 0 {
size = 10 * int64(datasize.GB)
size = 10 * uint64(datasize.GB)
}
if disk == "" {
disk = "disk0"
@ -171,14 +181,23 @@ func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size int64, o
// }
b := &builder{
osRelease: osRelease,
config: config,
bootloader: bl,
img: img,
diskRaw: filepath.Join(workdir, disk+".d2vm.raw"),
diskOut: filepath.Join(workdir, disk+"."+format),
format: f,
size: size,
mbrPath: mbrBin,
mntPoint: filepath.Join(workdir, "/mnt"),
cmdLineExtra: cmdLineExtra,
splitBoot: splitBoot,
bootSize: bootSize,
bootFS: bootFS,
luksPassword: luksPassword,
arch: arch,
}
if err := b.checkDependencies(); err != nil {
return nil, err
}
if err := os.MkdirAll(b.mntPoint, os.ModePerm); err != nil {
return nil, err
@ -214,15 +233,12 @@ func (b *builder) Build(ctx context.Context) (err error) {
if err = b.setupRootFS(ctx); err != nil {
return err
}
if err = b.installKernel(ctx); err != nil {
if err = b.installBootloader(ctx); err != nil {
return err
}
if err = b.unmountImg(ctx); err != nil {
return err
}
if err = b.setupMBR(ctx); err != nil {
return err
}
if err = b.convert2Img(ctx); err != nil {
return err
}
@ -245,9 +261,21 @@ func (b *builder) makeImg(ctx context.Context) error {
return err
}
if err := exec.Run(ctx, "parted", "-s", b.diskRaw, "mklabel", "msdos", "mkpart", "primary", "1Mib", "100%", "set", "1", "boot", "on"); err != nil {
var args []string
if b.splitBoot {
args = []string{"-s", b.diskRaw,
"mklabel", "msdos", "mkpart", "primary", "1Mib", fmt.Sprintf("%dMib", b.bootSize),
"mkpart", "primary", fmt.Sprintf("%dMib", b.bootSize), "100%",
"set", "1", "boot", "on",
}
} else {
args = []string{"-s", b.diskRaw, "mklabel", "msdos", "mkpart", "primary", "1Mib", "100%", "set", "1", "boot", "on"}
}
if err := exec.Run(ctx, "parted", args...); err != nil {
return err
}
return nil
}
@ -258,15 +286,65 @@ func (b *builder) mountImg(ctx context.Context) error {
return err
}
b.loDevice = strings.TrimSuffix(o, "\n")
if err := exec.Run(ctx, "partprobe", b.loDevice); err != nil {
if err := exec.Run(ctx, "kpartx", "-a", b.loDevice); err != nil {
return err
}
b.loPart = fmt.Sprintf("%sp1", b.loDevice)
logrus.Infof("creating raw image file system")
if err := exec.Run(ctx, "mkfs.ext4", b.loPart); err != nil {
b.bootPart = fmt.Sprintf("/dev/mapper/%sp1", filepath.Base(b.loDevice))
b.rootPart = ifElse(b.splitBoot, fmt.Sprintf("/dev/mapper/%sp2", filepath.Base(b.loDevice)), b.bootPart)
if b.isLuksEnabled() {
logrus.Infof("encrypting root partition")
f, err := os.CreateTemp("", "key")
if err != nil {
return err
}
defer f.Close()
defer os.Remove(f.Name())
if _, err := f.WriteString(b.luksPassword); err != nil {
return err
}
// cryptsetup luksFormat --batch-mode --verify-passphrase --type luks2 $ROOT_DEVICE $KEY_FILE
if err := exec.Run(ctx, "cryptsetup", "luksFormat", "--batch-mode", "--type", "luks2", b.rootPart, f.Name()); err != nil {
return err
}
b.cryptRoot = fmt.Sprintf("d2vm-%s-root", uuid.New().String())
// cryptsetup open -d $KEY_FILE $ROOT_DEVICE $ROOT_LABEL
if err := exec.Run(ctx, "cryptsetup", "open", "--key-file", f.Name(), b.rootPart, b.cryptRoot); err != nil {
return err
}
b.cryptPart = b.rootPart
b.rootPart = "/dev/mapper/root"
b.mappedCryptRoot = filepath.Join("/dev/mapper", b.cryptRoot)
logrus.Infof("creating raw image file system")
if err := exec.Run(ctx, "mkfs.ext4", b.mappedCryptRoot); err != nil {
return err
}
if err := exec.Run(ctx, "mount", b.mappedCryptRoot, b.mntPoint); err != nil {
return err
}
} else {
logrus.Infof("creating raw image file system")
if err := exec.Run(ctx, "mkfs.ext4", b.rootPart); err != nil {
return err
}
if err := exec.Run(ctx, "mount", b.rootPart, b.mntPoint); err != nil {
return err
}
}
if !b.splitBoot {
return nil
}
if err := os.MkdirAll(filepath.Join(b.mntPoint, "boot"), os.ModePerm); err != nil {
return err
}
if err := exec.Run(ctx, "mount", b.loPart, b.mntPoint); err != nil {
if b.bootFS.IsFat() {
err = exec.Run(ctx, "mkfs.fat", "-F32", b.bootPart)
} else {
err = exec.Run(ctx, "mkfs.ext4", b.bootPart)
}
if err != nil {
return err
}
if err := exec.Run(ctx, "mount", b.bootPart, filepath.Join(b.mntPoint, "boot")); err != nil {
return err
}
return nil
@ -275,13 +353,18 @@ func (b *builder) mountImg(ctx context.Context) error {
func (b *builder) unmountImg(ctx context.Context) error {
logrus.Infof("unmounting raw image")
var merr error
if err := exec.Run(ctx, "umount", b.mntPoint); err != nil {
merr = multierr.Append(merr, err)
if b.splitBoot {
merr = multierr.Append(merr, exec.Run(ctx, "umount", filepath.Join(b.mntPoint, "boot")))
}
if err := exec.Run(ctx, "losetup", "-d", b.loDevice); err != nil {
merr = multierr.Append(merr, err)
merr = multierr.Append(merr, exec.Run(ctx, "umount", b.mntPoint))
if b.isLuksEnabled() {
merr = multierr.Append(merr, exec.Run(ctx, "cryptsetup", "close", b.mappedCryptRoot))
}
return merr
return multierr.Combine(
merr,
exec.Run(ctx, "kpartx", "-d", b.loDevice),
exec.Run(ctx, "losetup", "-d", b.loDevice),
)
}
func (b *builder) copyRootFS(ctx context.Context) error {
@ -292,14 +375,37 @@ func (b *builder) copyRootFS(ctx context.Context) error {
return nil
}
func (b *builder) setupRootFS(ctx context.Context) error {
func diskUUID(ctx context.Context, disk string) (string, error) {
o, _, err := exec.RunOut(ctx, "blkid", "-s", "UUID", "-o", "value", disk)
if err != nil {
return "", err
}
return strings.TrimSuffix(o, "\n"), nil
}
func (b *builder) setupRootFS(ctx context.Context) (err error) {
logrus.Infof("setting up rootfs")
o, _, err := exec.RunOut(ctx, "blkid", "-s", "UUID", "-o", "value", b.loPart)
b.rootUUID, err = diskUUID(ctx, ifElse(b.isLuksEnabled(), b.mappedCryptRoot, b.rootPart))
if err != nil {
return err
}
b.diskUUD = strings.TrimSuffix(o, "\n")
fstab := fmt.Sprintf("UUID=%s / ext4 errors=remount-ro 0 1\n", b.diskUUD)
var fstab string
if b.splitBoot {
b.bootUUID, err = diskUUID(ctx, b.bootPart)
if err != nil {
return err
}
if b.isLuksEnabled() {
b.cryptUUID, err = diskUUID(ctx, b.cryptPart)
if err != nil {
return err
}
}
fstab = fmt.Sprintf("UUID=%s / ext4 errors=remount-ro 0 1\nUUID=%s /boot %s errors=remount-ro 0 2\n", b.rootUUID, b.bootUUID, b.bootFS.linux())
} else {
b.bootUUID = b.rootUUID
fstab = fmt.Sprintf("UUID=%s / ext4 errors=remount-ro 0 1\n", b.bootUUID)
}
if err := b.chWriteFile("/etc/fstab", fstab, perm); err != nil {
return err
}
@ -319,44 +425,46 @@ func (b *builder) setupRootFS(ctx context.Context) error {
if err := os.RemoveAll(b.chPath("/.dockerenv")); err != nil {
return err
}
if b.osRelease.ID != ReleaseAlpine {
switch b.osRelease.ID {
case ReleaseAlpine:
by, err := os.ReadFile(b.chPath("/etc/inittab"))
if err != nil {
return err
}
by = append(by, []byte("\n"+"ttyS0::respawn:/sbin/getty -L ttyS0 115200 vt100\n")...)
if err := b.chWriteFile("/etc/inittab", string(by), perm); err != nil {
return err
}
if err := b.chWriteFileIfNotExist("/etc/network/interfaces", "", perm); err != nil {
return err
}
return nil
default:
return nil
}
by, err := os.ReadFile(b.chPath("/etc/inittab"))
if err != nil {
return err
}
by = append(by, []byte("\n"+"ttyS0::respawn:/sbin/getty -L ttyS0 115200 vt100\n")...)
if err := b.chWriteFile("/etc/inittab", string(by), perm); err != nil {
return err
}
if err := b.chWriteFileIfNotExist("/etc/network/interfaces", "", perm); err != nil {
return err
}
return nil
}
func (b *builder) installKernel(ctx context.Context) error {
logrus.Infof("installing linux kernel")
if err := exec.Run(ctx, "extlinux", "--install", b.chPath("/boot")); err != nil {
return err
func (b *builder) cmdline(_ context.Context) string {
if !b.isLuksEnabled() {
return b.config.Cmdline(RootUUID(b.rootUUID), b.cmdLineExtra)
}
sysconfig, err := sysconfig(b.osRelease)
if err != nil {
return err
switch b.osRelease.ID {
case ReleaseAlpine:
return b.config.Cmdline(RootUUID(b.rootUUID), "root=/dev/mapper/root", "cryptdm=root", "cryptroot=UUID="+b.cryptUUID, b.cmdLineExtra)
case ReleaseCentOS:
return b.config.Cmdline(RootUUID(b.rootUUID), "rd.luks.name=UUID="+b.rootUUID+" rd.luks.uuid="+b.cryptUUID+" rd.luks.crypttab=0", b.cmdLineExtra)
default:
// for some versions of debian, the cryptopts parameter MUST contain all the following: target,source,key,opts...
// see https://salsa.debian.org/cryptsetup-team/cryptsetup/-/blob/debian/buster/debian/functions
// and https://cryptsetup-team.pages.debian.net/cryptsetup/README.initramfs.html
return b.config.Cmdline(nil, "root=/dev/mapper/root", "cryptopts=target=root,source=UUID="+b.cryptUUID+",key=none,luks", b.cmdLineExtra)
}
if err := b.chWriteFile("/boot/syslinux.cfg", fmt.Sprintf(sysconfig, b.diskUUD, b.cmdLineExtra), perm); err != nil {
return err
}
return nil
}
func (b *builder) setupMBR(ctx context.Context) error {
logrus.Infof("writing MBR")
if err := exec.Run(ctx, "dd", fmt.Sprintf("if=%s", b.mbrPath), fmt.Sprintf("of=%s", b.diskRaw), "bs=440", "count=1", "conv=notrunc"); err != nil {
return err
}
return nil
func (b *builder) installBootloader(ctx context.Context) error {
logrus.Infof("installing bootloader")
return b.bootloader.Setup(ctx, b.loDevice, b.mntPoint, b.cmdline(ctx))
}
func (b *builder) convert2Img(ctx context.Context) error {
@ -379,22 +487,30 @@ func (b *builder) chPath(path string) string {
return fmt.Sprintf("%s%s", b.mntPoint, path)
}
func (b *builder) isLuksEnabled() bool {
return b.luksPassword != ""
}
func (b *builder) Close() error {
return b.img.Close()
}
func block(path string, size int64) error {
func block(path string, size uint64) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return f.Truncate(size)
return f.Truncate(int64(size))
}
func checkDependencies() error {
func (b *builder) checkDependencies() error {
var merr error
for _, v := range []string{"mount", "blkid", "tar", "losetup", "parted", "partprobe", "qemu-img", "extlinux", "dd", "mkfs"} {
deps := []string{"mount", "blkid", "tar", "losetup", "parted", "kpartx", "qemu-img", "dd", "mkfs.ext4", "cryptsetup"}
if _, ok := b.bootloader.(*syslinux); ok {
deps = append(deps, "extlinux")
}
for _, v := range deps {
if _, err := exec2.LookPath(v); err != nil {
merr = multierr.Append(merr, err)
}
@ -405,3 +521,10 @@ func checkDependencies() error {
func OutputFormats() []string {
return formats[:]
}
func ifElse(v bool, t string, f string) string {
if v {
return t
}
return f
}

View File

@ -1,148 +0,0 @@
// Copyright 2022 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.linka.cloud/d2vm/pkg/docker"
"go.linka.cloud/d2vm/pkg/exec"
)
func testSysconfig(t *testing.T, ctx context.Context, img, sysconf, kernel, initrd string) {
require.NoError(t, docker.Pull(ctx, img))
tmpPath := filepath.Join(os.TempDir(), "d2vm-tests", strings.NewReplacer(":", "-", ".", "-").Replace(img))
require.NoError(t, os.MkdirAll(tmpPath, 0755))
defer os.RemoveAll(tmpPath)
logrus.Infof("inspecting image %s", img)
r, err := FetchDockerImageOSRelease(ctx, img, tmpPath)
require.NoError(t, err)
defer docker.Remove(ctx, img)
sys, err := sysconfig(r)
require.NoError(t, err)
assert.Equal(t, sysconf, sys)
d, err := NewDockerfile(r, img, "root", "")
require.NoError(t, err)
logrus.Infof("docker image based on %s", d.Release.Name)
p := filepath.Join(tmpPath, docker.FormatImgName(img))
dir := filepath.Dir(p)
f, err := os.Create(p)
require.NoError(t, err)
defer f.Close()
require.NoError(t, d.Render(f))
imgUUID := uuid.New().String()
logrus.Infof("building kernel enabled image")
require.NoError(t, docker.Build(ctx, imgUUID, p, dir))
defer docker.Remove(ctx, imgUUID)
require.NoError(t, docker.RunAndRemove(ctx, imgUUID, "test", "-f", kernel))
require.NoError(t, docker.RunAndRemove(ctx, imgUUID, "test", "-f", initrd))
}
func TestSyslinuxCfg(t *testing.T) {
t.Parallel()
tests := []struct {
image string
kernel string
initrd string
sysconfig string
}{
{
image: "ubuntu:18.04",
kernel: "/vmlinuz",
initrd: "/initrd.img",
sysconfig: syslinuxCfgDebian,
},
{
image: "ubuntu:20.04",
kernel: "/boot/vmlinuz",
initrd: "/boot/initrd.img",
sysconfig: syslinuxCfgUbuntu,
},
{
image: "ubuntu:22.04",
kernel: "/boot/vmlinuz",
initrd: "/boot/initrd.img",
sysconfig: syslinuxCfgUbuntu,
},
{
image: "ubuntu:latest",
kernel: "/boot/vmlinuz",
initrd: "/boot/initrd.img",
sysconfig: syslinuxCfgUbuntu,
},
{
image: "debian:9",
kernel: "/vmlinuz",
initrd: "/initrd.img",
sysconfig: syslinuxCfgDebian,
},
{
image: "debian:10",
kernel: "/vmlinuz",
initrd: "/initrd.img",
sysconfig: syslinuxCfgDebian,
},
{
image: "debian:11",
kernel: "/vmlinuz",
initrd: "/initrd.img",
sysconfig: syslinuxCfgDebian,
},
{
image: "debian:latest",
kernel: "/vmlinuz",
initrd: "/initrd.img",
sysconfig: syslinuxCfgDebian,
},
{
image: "alpine",
kernel: "/boot/vmlinuz-virt",
initrd: "/boot/initramfs-virt",
sysconfig: syslinuxCfgAlpine,
},
{
image: "centos:8",
kernel: "/boot/vmlinuz",
initrd: "/boot/initrd.img",
sysconfig: syslinuxCfgCentOS,
},
{
image: "centos:latest",
kernel: "/boot/vmlinuz",
initrd: "/boot/initrd.img",
sysconfig: syslinuxCfgCentOS,
},
}
exec.SetDebug(true)
for _, test := range tests {
test := test
t.Run(test.image, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
testSysconfig(t, ctx, test.image, test.sysconfig, test.kernel, test.initrd)
})
}
}

View File

@ -30,17 +30,16 @@ import (
)
var (
file = "Dockerfile"
tag = "d2vm-" + uuid.New().String()
networkManager string
buildArgs []string
buildCmd = &cobra.Command{
file = "Dockerfile"
tag = "d2vm-" + uuid.New().String()
buildArgs []string
buildCmd = &cobra.Command{
Use: "build [context directory]",
Short: "Build a vm image from Dockerfile",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// TODO(adphi): resolve context path
if runtime.GOOS != "linux" {
if runtime.GOOS != "linux" || !isRoot() {
ctxAbsPath, err := filepath.Abs(args[0])
if err != nil {
return err
@ -80,6 +79,9 @@ var (
}
return docker.RunD2VM(cmd.Context(), d2vm.Image, d2vm.Version, in, out, cmd.Name(), os.Args[2:]...)
}
if err := validateFlags(); err != nil {
return err
}
size, err := parseSize(size)
if err != nil {
return err
@ -87,16 +89,11 @@ var (
if file == "" {
file = filepath.Join(args[0], "Dockerfile")
}
if _, err := os.Stat(output); err == nil || !os.IsNotExist(err) {
if !force {
return fmt.Errorf("%s already exists", output)
}
}
logrus.Infof("building docker image from %s", file)
if err := docker.Build(cmd.Context(), tag, file, args[0], buildArgs...); err != nil {
if err := docker.Build(cmd.Context(), pull, tag, file, args[0], platform, buildArgs...); err != nil {
return err
}
return d2vm.Convert(
if err := d2vm.Convert(
cmd.Context(),
tag,
d2vm.WithSize(size),
@ -104,8 +101,24 @@ var (
d2vm.WithOutput(output),
d2vm.WithCmdLineExtra(cmdLineExtra),
d2vm.WithNetworkManager(d2vm.NetworkManager(networkManager)),
d2vm.WithBootLoader(bootloader),
d2vm.WithRaw(raw),
)
d2vm.WithSplitBoot(splitBoot),
d2vm.WithBootSize(bootSize),
d2vm.WithBootFS(d2vm.BootFS(bootFS)),
d2vm.WithLuksPassword(luksPassword),
d2vm.WithKeepCache(keepCache),
d2vm.WithPlatform(platform),
d2vm.WithPull(false),
); err != nil {
return err
}
if uid, ok := sudoUser(); ok {
if err := os.Chown(output, uid, uid); err != nil {
return err
}
}
return maybeMakeContainerDisk(cmd.Context())
},
}
)
@ -116,11 +129,5 @@ func init() {
buildCmd.Flags().StringVarP(&file, "file", "f", "", "Name of the Dockerfile")
buildCmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Set build-time variables")
buildCmd.Flags().StringVarP(&output, "output", "o", output, "The output image, the extension determine the image format, raw will be used if none. Supported formats: "+strings.Join(d2vm.OutputFormats(), " "))
buildCmd.Flags().StringVarP(&password, "password", "p", "root", "Root user password")
buildCmd.Flags().StringVarP(&size, "size", "s", "10G", "The output image size")
buildCmd.Flags().BoolVar(&force, "force", false, "Override output image")
buildCmd.Flags().StringVar(&cmdLineExtra, "append-to-cmdline", "", "Extra kernel cmdline arguments to append to the generated one")
buildCmd.Flags().StringVar(&networkManager, "network-manager", "", "Network manager to use for the image: none, netplan, ifupdown")
buildCmd.Flags().BoolVar(&raw, "raw", false, "Just convert the container to virtual machine image without installing anything more")
buildCmd.Flags().AddFlagSet(buildFlags())
}

View File

@ -0,0 +1,43 @@
// Copyright 2022 Linka Cloud All rights reserved.
//
// Licensed 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.
package main
import (
"context"
"fmt"
"github.com/sirupsen/logrus"
"go.linka.cloud/d2vm"
"go.linka.cloud/d2vm/pkg/docker"
)
func maybeMakeContainerDisk(ctx context.Context) error {
if containerDiskTag == "" {
return nil
}
logrus.Infof("creating container disk image %s", containerDiskTag)
if err := d2vm.MakeContainerDisk(ctx, output, containerDiskTag, platform); err != nil {
return err
}
if !push {
return nil
}
logrus.Infof("pushing container disk image %s", containerDiskTag)
if err := docker.Push(ctx, containerDiskTag); err != nil {
return fmt.Errorf("failed to push container disk: %w", err)
}
return nil
}

View File

@ -15,11 +15,9 @@
package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/c2h5oh/datasize"
"github.com/sirupsen/logrus"
@ -30,17 +28,13 @@ import (
)
var (
raw bool
pull = false
cmdLineExtra = ""
convertCmd = &cobra.Command{
Use: "convert [docker image]",
Short: "Convert Docker image to vm image",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if runtime.GOOS != "linux" {
if runtime.GOOS != "linux" || !isRoot() {
abs, err := filepath.Abs(output)
if err != nil {
return err
@ -55,38 +49,32 @@ var (
}
return docker.RunD2VM(cmd.Context(), d2vm.Image, d2vm.Version, out, out, cmd.Name(), dargs...)
}
img := args[0]
tag := "latest"
if parts := strings.Split(img, ":"); len(parts) > 1 {
img, tag = parts[0], parts[1]
if err := validateFlags(); err != nil {
return err
}
size, err := parseSize(size)
if err != nil {
return err
}
if _, err := os.Stat(output); err == nil || !os.IsNotExist(err) {
if !force {
return fmt.Errorf("%s already exists", output)
}
}
img := args[0]
found := false
if !pull {
imgs, err := docker.ImageList(cmd.Context(), img)
if err != nil {
return err
}
found = len(imgs) == 1 && imgs[0] == fmt.Sprintf("%s:%s", img, tag)
found = len(imgs) == 1 && imgs[0] == img
if found {
logrus.Infof("using local image %s:%s", img, tag)
logrus.Infof("using local image %s", img)
}
}
if pull || !found {
logrus.Infof("pulling image %s", img)
if err := docker.Pull(cmd.Context(), img); err != nil {
if err := docker.Pull(cmd.Context(), platform, img); err != nil {
return err
}
}
return d2vm.Convert(
if err := d2vm.Convert(
cmd.Context(),
img,
d2vm.WithSize(size),
@ -94,28 +82,38 @@ var (
d2vm.WithOutput(output),
d2vm.WithCmdLineExtra(cmdLineExtra),
d2vm.WithNetworkManager(d2vm.NetworkManager(networkManager)),
d2vm.WithBootLoader(bootloader),
d2vm.WithRaw(raw),
)
d2vm.WithSplitBoot(splitBoot),
d2vm.WithBootSize(bootSize),
d2vm.WithBootFS(d2vm.BootFS(bootFS)),
d2vm.WithLuksPassword(luksPassword),
d2vm.WithKeepCache(keepCache),
d2vm.WithPlatform(platform),
d2vm.WithPull(pull),
); err != nil {
return err
}
// set user permissions on the output file if the command was run with sudo
if uid, ok := sudoUser(); ok {
if err := os.Chown(output, uid, uid); err != nil {
return err
}
}
return maybeMakeContainerDisk(cmd.Context())
},
}
)
func parseSize(s string) (int64, error) {
func parseSize(s string) (uint64, error) {
var v datasize.ByteSize
if err := v.UnmarshalText([]byte(s)); err != nil {
return 0, err
}
return int64(v), nil
return uint64(v), nil
}
func init() {
convertCmd.Flags().BoolVar(&pull, "pull", false, "Always pull docker image")
convertCmd.Flags().StringVarP(&output, "output", "o", output, "The output image, the extension determine the image format, raw will be used if none. Supported formats: "+strings.Join(d2vm.OutputFormats(), " "))
convertCmd.Flags().StringVarP(&password, "password", "p", "root", "The Root user password")
convertCmd.Flags().StringVarP(&size, "size", "s", "10G", "The output image size")
convertCmd.Flags().BoolVarP(&force, "force", "f", false, "Override output qcow2 image")
convertCmd.Flags().StringVar(&cmdLineExtra, "append-to-cmdline", "", "Extra kernel cmdline arguments to append to the generated one")
convertCmd.Flags().StringVar(&networkManager, "network-manager", "", "Network manager to use for the image: none, netplan, ifupdown")
convertCmd.Flags().BoolVar(&raw, "raw", false, "Just convert the container to virtual machine image without installing anything more")
convertCmd.Flags().AddFlagSet(buildFlags())
rootCmd.AddCommand(convertCmd)
}

120
cmd/d2vm/flags.go Normal file
View File

@ -0,0 +1,120 @@
// Copyright 2022 Linka Cloud All rights reserved.
//
// Licensed 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.
package main
import (
"fmt"
"os"
"strings"
"github.com/sirupsen/logrus"
"github.com/spf13/pflag"
"go.linka.cloud/d2vm"
)
var (
output = "disk0.qcow2"
size = "1G"
password = ""
force = false
raw bool
pull = false
cmdLineExtra = ""
containerDiskTag = ""
push bool
networkManager string
bootloader string
splitBoot bool
bootSize uint64
bootFS string
luksPassword string
keepCache bool
platform string
)
func validateFlags() error {
switch platform {
case "linux/amd64":
if bootloader == "" {
bootloader = "syslinux"
}
case "linux/arm64", "linux/aarch64":
platform = "linux/arm64"
if bootloader == "" {
bootloader = "grub-efi"
}
if bootloader != "grub-efi" {
return fmt.Errorf("unsupported bootloader for platform %s: %s, only grub-efi is supported", platform, bootloader)
}
default:
return fmt.Errorf("unexpected platform: %s, supported platforms: linux/amd64, linux/arm64", platform)
}
if luksPassword != "" && !splitBoot {
logrus.Warnf("luks password is set: enabling split boot")
splitBoot = true
}
if bootFS := d2vm.BootFS(bootFS); bootFS != "" && !bootFS.IsSupported() {
return fmt.Errorf("invalid boot filesystem: %s", bootFS)
}
if bootFS != "" && !splitBoot {
logrus.Warnf("boot filesystem is set: enabling split boot")
splitBoot = true
}
efi := bootloader == "grub-efi" || bootloader == "grub"
if efi && !splitBoot {
logrus.Warnf("grub-efi bootloader is set: enabling split boot")
splitBoot = true
}
if efi && bootFS != "" && bootFS != "fat32" {
return fmt.Errorf("grub-efi bootloader only supports fat32 boot filesystem")
}
if efi && bootFS == "" {
logrus.Warnf("grub-efi bootloader is set: enabling fat32 boot filesystem")
bootFS = "fat32"
}
if push && tag == "" {
return fmt.Errorf("tag is required when pushing container disk image")
}
if _, err := os.Stat(output); err == nil || !os.IsNotExist(err) {
if !force {
return fmt.Errorf("%s already exists", output)
}
}
return nil
}
func buildFlags() *pflag.FlagSet {
flags := pflag.NewFlagSet("build", pflag.ExitOnError)
flags.StringVarP(&output, "output", "o", output, "The output image, the extension determine the image format, raw will be used if none. Supported formats: "+strings.Join(d2vm.OutputFormats(), " "))
flags.StringVarP(&password, "password", "p", "", "Optional root user password")
flags.StringVarP(&size, "size", "s", "10G", "The output image size")
flags.BoolVar(&force, "force", false, "Override output qcow2 image")
flags.StringVar(&cmdLineExtra, "append-to-cmdline", "", "Extra kernel cmdline arguments to append to the generated one")
flags.StringVar(&networkManager, "network-manager", "", "Network manager to use for the image: none, netplan, ifupdown")
flags.BoolVar(&raw, "raw", false, "Just convert the container to virtual machine image without installing anything more")
flags.StringVarP(&containerDiskTag, "tag", "t", "", "Container disk Docker image tag")
flags.BoolVar(&push, "push", false, "Push the container disk image to the registry")
flags.BoolVar(&splitBoot, "split-boot", false, "Split the boot partition from the root partition")
flags.Uint64Var(&bootSize, "boot-size", 100, "Size of the boot partition in MB")
flags.StringVar(&bootFS, "boot-fs", "", "Filesystem to use for the boot partition, ext4 or fat32")
flags.StringVar(&bootloader, "bootloader", "", "Bootloader to use: syslinux, grub, grub-bios, grub-efi, defaults to syslinux on amd64 and grub-efi on arm64")
flags.StringVar(&luksPassword, "luks-password", "", "Password to use for the LUKS encrypted root partition. If not set, the root partition will not be encrypted")
flags.BoolVar(&keepCache, "keep-cache", false, "Keep the images after the build")
flags.StringVar(&platform, "platform", d2vm.Arch, "Platform to use for the container disk image, linux/arm64 and linux/arm64 are supported")
flags.BoolVar(&pull, "pull", false, "Always pull docker image")
return flags
}

View File

@ -20,6 +20,8 @@ import (
"fmt"
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"time"
@ -32,10 +34,6 @@ import (
)
var (
output = "disk0.qcow2"
size = "1G"
password = "root"
force = false
verbose = false
timeFormat = ""
format = "qcow2"
@ -56,6 +54,12 @@ var (
logrus.SetLevel(logrus.TraceLevel)
}
exec.SetDebug(verbose)
// make the zsh completion work when sourced with `source <(d2vm completion zsh)`
if cmd.Name() == "zsh" && cmd.Parent() != nil && cmd.Parent().Name() == "completion" {
zshHead := fmt.Sprintf("#compdef %[1]s\ncompdef _%[1]s %[1]s\n", cmd.Root().Name())
cmd.OutOrStdout().Write([]byte(zshHead))
}
},
}
)
@ -71,14 +75,16 @@ func main() {
fmt.Println()
cancel()
}()
rootCmd.ExecuteContext(ctx)
if err := rootCmd.ExecuteContext(ctx); err != nil {
logrus.Fatal(err)
}
}
func init() {
rootCmd.PersistentFlags().BoolVarP(&verbose, "debug", "d", false, "Enable Debug output")
rootCmd.PersistentFlags().MarkDeprecated("debug", "use -v instead")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable Verbose output")
rootCmd.PersistentFlags().StringVarP(&timeFormat, "time", "t", "none", "Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)'")
rootCmd.PersistentFlags().StringVar(&timeFormat, "time", "none", "Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)'")
color.NoColor = false
logrus.StandardLogger().Formatter = &logfmtFormatter{start: time.Now()}
}
@ -127,3 +133,25 @@ func (f *logfmtFormatter) Format(entry *logrus.Entry) ([]byte, error) {
}
return b.Bytes(), nil
}
func isRoot() bool {
return os.Geteuid() == 0
}
func sudoUser() (uid int, sudo bool) {
// if we are not running on linux, docker handle files user's permissions,
// so we don't need to check for sudo here
if runtime.GOOS != "linux" {
return
}
v := os.Getenv("SUDO_UID")
if v == "" {
return 0, false
}
uid, err := strconv.Atoi(v)
if err != nil {
logrus.Errorf("invalid SUDO_UID: %s", v)
return 0, false
}
return uid, true
}

View File

@ -24,6 +24,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
@ -34,13 +35,14 @@ import (
"github.com/spf13/cobra"
"github.com/svenwiltink/sparsecat"
exec2 "go.linka.cloud/d2vm/pkg/exec"
"go.linka.cloud/d2vm/pkg/qemu_img"
)
const (
hetznerTokenEnv = "HETZNER_TOKEN"
serverImg = "ubuntu-20.04"
vmBlockPath = "/dev/sda"
vmBlock = "sda"
vmBlockPath = "/dev/" + vmBlock
sparsecatPath = "/usr/local/bin/sparsecat"
)
@ -68,6 +70,8 @@ func init() {
HetznerCmd.Flags().StringVarP(&hetznerSSHKeyPath, "ssh-key", "i", "", "d2vm image identity key")
HetznerCmd.Flags().BoolVar(&hetznerRemove, "rm", false, "remove server when done")
HetznerCmd.Flags().StringVarP(&hetznerServerName, "name", "n", "d2vm", "d2vm server name")
HetznerCmd.Flags().StringVarP(&hetznerVMType, "type", "t", hetznerVMType, "d2vm server type")
HetznerCmd.Flags().StringVarP(&hetznerDatacenter, "location", "l", hetznerDatacenter, "d2vm server location")
}
func Hetzner(cmd *cobra.Command, args []string) {
@ -79,7 +83,7 @@ func Hetzner(cmd *cobra.Command, args []string) {
}
func runHetzner(ctx context.Context, imgPath string, stdin io.Reader, stderr io.Writer, stdout io.Writer) error {
i, err := ImgInfo(ctx, imgPath)
i, err := qemu_img.Info(ctx, imgPath)
if err != nil {
return err
}
@ -91,11 +95,11 @@ func runHetzner(ctx context.Context, imgPath string, stdin io.Reader, stderr io.
}
defer os.RemoveAll(rawPath)
logrus.Infof("converting image to raw: %s", rawPath)
if err := exec2.Run(ctx, "qemu-img", "convert", "-O", "raw", imgPath, rawPath); err != nil {
if err := qemu_img.Convert(ctx, "raw", imgPath, rawPath); err != nil {
return err
}
imgPath = rawPath
i, err = ImgInfo(ctx, imgPath)
i, err = qemu_img.Info(ctx, imgPath)
if err != nil {
return err
}
@ -111,10 +115,23 @@ func runHetzner(ctx context.Context, imgPath string, stdin io.Reader, stderr io.
if err != nil {
return err
}
img, _, err := c.Image.GetByName(ctx, serverImg)
arch := "amd64"
harch := hcloud.ArchitectureX86
if strings.HasPrefix(strings.ToLower(hetznerVMType), "cax") {
harch = hcloud.ArchitectureARM
arch = "arm64"
}
sparsecatBin, err := Sparsecat(arch)
if err != nil {
return err
}
imgs, _, err := c.Image.List(ctx, hcloud.ImageListOpts{Name: serverImg, Architecture: []hcloud.Architecture{harch}})
if err != nil {
return err
}
if len(imgs) == 0 {
return fmt.Errorf("no image found with name %s", serverImg)
}
l, _, err := c.Location.Get(ctx, hetznerDatacenter)
if err != nil {
return err
@ -123,9 +140,9 @@ func runHetzner(ctx context.Context, imgPath string, stdin io.Reader, stderr io.
sres, _, err := c.Server.Create(ctx, hcloud.ServerCreateOpts{
Name: hetznerServerName,
ServerType: st,
Image: img,
Image: imgs[0],
Location: l,
StartAfterCreate: hcloud.Bool(false),
StartAfterCreate: hcloud.Ptr(false),
})
if err != nil {
return err
@ -184,7 +201,7 @@ func runHetzner(ctx context.Context, imgPath string, stdin io.Reader, stderr io.
return err
}
defer f.Close()
if _, err := io.Copy(f, bytes.NewReader(sparsecatBinary)); err != nil {
if _, err := io.Copy(f, bytes.NewReader(sparsecatBin)); err != nil {
return err
}
if err := f.Close(); err != nil {
@ -243,7 +260,12 @@ func runHetzner(ctx context.Context, imgPath string, stdin io.Reader, stderr io.
}
}
}()
cmd := fmt.Sprintf("%s -r -disable-sparse-target -of %s", sparsecatPath, vmBlockPath)
var cmd string
if runtime.GOOS == "linux" {
cmd = fmt.Sprintf("%s -r -disable-sparse-target -of %s", sparsecatPath, vmBlockPath)
} else {
cmd = fmt.Sprintf("dd of=%s", vmBlockPath)
}
logrus.Debugf("$ %s", cmd)
if b, err := wses.CombinedOutput(cmd); err != nil {
return fmt.Errorf("%v: %s", err, string(b))
@ -263,13 +285,31 @@ func runHetzner(ctx context.Context, imgPath string, stdin io.Reader, stderr io.
return ctx.Err()
}
}
nses, err := sc.NewSession()
if err != nil {
return err
}
defer nses.Close()
// retrieve the partition number
cmd := fmt.Sprintf("ls %s*", vmBlockPath)
logrus.Debugf("$ %s", cmd)
b, err := nses.CombinedOutput(cmd)
if err != nil {
return fmt.Errorf("%v: %s", err, string(b))
}
logrus.Debugf(string(b))
parts := strings.Fields(strings.TrimSuffix(string(b), "\n"))
vmPartNumber, err := strconv.Atoi(strings.Replace(parts[len(parts)-1], vmBlockPath, "", 1))
if err != nil {
return err
}
gses, err := sc.NewSession()
if err != nil {
return err
}
defer gses.Close()
logrus.Infof("resizing disk partition")
cmd := fmt.Sprintf("growpart %s 1", vmBlockPath)
cmd = fmt.Sprintf("growpart %s %d", vmBlockPath, vmPartNumber)
logrus.Debugf("$ %s", cmd)
if b, err := gses.CombinedOutput(cmd); err != nil {
return fmt.Errorf("%v: %s", err, string(b))
@ -282,7 +322,7 @@ func runHetzner(ctx context.Context, imgPath string, stdin io.Reader, stderr io.
}
defer cses.Close()
logrus.Infof("checking disk partition")
cmd = fmt.Sprintf("e2fsck -yf %s1", vmBlockPath)
cmd = fmt.Sprintf("e2fsck -yf %s%d", vmBlockPath, vmPartNumber)
logrus.Debugf("$ %s", cmd)
if b, err := cses.CombinedOutput(cmd); err != nil {
return fmt.Errorf("%v: %s", err, string(b))
@ -295,7 +335,7 @@ func runHetzner(ctx context.Context, imgPath string, stdin io.Reader, stderr io.
}
defer eses.Close()
logrus.Infof("extending partition file system")
cmd = fmt.Sprintf("resize2fs %s1", vmBlockPath)
cmd = fmt.Sprintf("resize2fs %s%d", vmBlockPath, vmPartNumber)
logrus.Debugf("$ %s", cmd)
if b, err := eses.CombinedOutput(cmd); err != nil {
return fmt.Errorf("%v: %s", err, string(b))

View File

@ -1,18 +1,13 @@
package run
import (
"crypto/rand"
"fmt"
"net"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"go.linka.cloud/d2vm/pkg/qemu"
)
const (
@ -33,12 +28,11 @@ var (
arch string
cpus uint
mem uint
bios string
qemuCmd string
qemuDetached bool
networking string
publishFlags MultipleFlag
deviceFlags MultipleFlag
usbEnabled bool
QemuCmd = &cobra.Command{
Use: "qemu [options] [image-path]",
@ -71,7 +65,6 @@ func init() {
// Paths and settings for disks
flags.Var(&disks, "disk", "Disk config, may be repeated. [file=]path[,size=1G][,format=qcow2]")
flags.StringVar(&data, "data", "", "String of metadata to pass to VM; error to specify both -data and -data-file")
// VM configuration
flags.StringVar(&accel, "accel", defaultAccel, "Choose acceleration mode. Use 'tcg' to disable it.")
@ -79,6 +72,8 @@ func init() {
flags.UintVar(&cpus, "cpus", 1, "Number of CPUs")
flags.UintVar(&mem, "mem", 1024, "Amount of memory in MB")
flags.StringVar(&bios, "bios", "", "Path to the optional bios binary")
// Backend configuration
flags.StringVar(&qemuCmd, "qemu", "", "Path to the qemu binary (otherwise look in $PATH)")
flags.BoolVar(&qemuDetached, "detached", false, "Set qemu container to run in the background")
@ -87,362 +82,46 @@ func init() {
flags.StringVar(&networking, "networking", qemuNetworkingDefault, "Networking mode. Valid options are 'default', 'user', 'bridge[,name]', tap[,name] and 'none'. 'user' uses QEMUs userspace networking. 'bridge' connects to a preexisting bridge. 'tap' uses a prexisting tap device. 'none' disables networking.`")
flags.Var(&publishFlags, "publish", "Publish a vm's port(s) to the host (default [])")
// USB devices
flags.BoolVar(&usbEnabled, "usb", false, "Enable USB controller")
flags.Var(&deviceFlags, "device", "Add USB host device(s). Format driver[,prop=value][,...] -- add device, like -device on the qemu command line.")
}
func Qemu(cmd *cobra.Command, args []string) {
// Generate UUID, so that /sys/class/dmi/id/product_uuid is populated
vmUUID := uuid.New()
// These envvars override the corresponding command line
// options. So this must remain after the `flags.Parse` above.
accel = GetStringValue("LINUXKIT_QEMU_ACCEL", accel, "")
path := args[0]
if _, err := os.Stat(path); err != nil {
log.Fatal(err)
}
for i, d := range disks {
id := ""
if i != 0 {
id = strconv.Itoa(i)
}
if d.Size != 0 && d.Format == "" {
d.Format = "qcow2"
}
if d.Size != 0 && d.Path == "" {
d.Path = "disk" + id + ".img"
}
if d.Path == "" {
log.Fatalf("disk specified with no size or name")
}
disks[i] = d
}
disks = append(Disks{DiskConfig{Path: path}}, disks...)
if networking == "" || networking == "default" {
dflt := qemuNetworkingDefault
networking = dflt
}
netMode := strings.SplitN(networking, ",", 2)
var netdevConfig string
switch netMode[0] {
case qemuNetworkingUser:
netdevConfig = "user,id=t0"
case qemuNetworkingTap:
if len(netMode) != 2 {
log.Fatalf("Not enough arguments for %q networking mode", qemuNetworkingTap)
}
if len(publishFlags) != 0 {
log.Fatalf("Port publishing requires %q networking mode", qemuNetworkingUser)
}
netdevConfig = fmt.Sprintf("tap,id=t0,ifname=%s,script=no,downscript=no", netMode[1])
case qemuNetworkingBridge:
if len(netMode) != 2 {
log.Fatalf("Not enough arguments for %q networking mode", qemuNetworkingBridge)
}
if len(publishFlags) != 0 {
log.Fatalf("Port publishing requires %q networking mode", qemuNetworkingUser)
}
netdevConfig = fmt.Sprintf("bridge,id=t0,br=%s", netMode[1])
case qemuNetworkingNone:
if len(publishFlags) != 0 {
log.Fatalf("Port publishing requires %q networking mode", qemuNetworkingUser)
}
netdevConfig = ""
default:
log.Fatalf("Invalid networking mode: %s", netMode[0])
}
config := QemuConfig{
Path: path,
GUI: enableGUI,
Disks: disks,
Arch: arch,
CPUs: cpus,
Memory: mem,
Accel: accel,
Detached: qemuDetached,
QemuBinPath: qemuCmd,
PublishedPorts: publishFlags,
NetdevConfig: netdevConfig,
UUID: vmUUID,
USB: usbEnabled,
Devices: deviceFlags,
}
config, err := discoverBinaries(config)
if err != nil {
log.Fatal(err)
}
if err = runQemuLocal(config); err != nil {
log.Fatal(err.Error())
}
}
func runQemuLocal(config QemuConfig) error {
var args []string
config, args = buildQemuCmdline(config)
for _, d := range config.Disks {
// If disk doesn't exist then create one
if _, err := os.Stat(d.Path); err != nil {
if os.IsNotExist(err) {
log.Debugf("Creating new qemu disk [%s] format %s", d.Path, d.Format)
qemuImgCmd := exec.Command(config.QemuImgPath, "create", "-f", d.Format, d.Path, fmt.Sprintf("%dM", d.Size))
log.Debugf("%v", qemuImgCmd.Args)
if err := qemuImgCmd.Run(); err != nil {
return fmt.Errorf("Error creating disk [%s] format %s: %s", d.Path, d.Format, err.Error())
}
} else {
return err
}
} else {
log.Infof("Using existing disk [%s] format %s", d.Path, d.Format)
}
}
// Detached mode is only supported in a container.
if config.Detached == true {
return fmt.Errorf("Detached mode is only supported when running in a container, not locally")
}
qemuCmd := exec.Command(config.QemuBinPath, args...)
// If verbosity is enabled print out the full path/arguments
log.Debugf("%v", qemuCmd.Args)
// If we're not using a separate window then link the execution to stdin/out
if config.GUI != true {
qemuCmd.Stdin = os.Stdin
qemuCmd.Stdout = os.Stdout
qemuCmd.Stderr = os.Stderr
}
return qemuCmd.Run()
}
func buildQemuCmdline(config QemuConfig) (QemuConfig, []string) {
// Iterate through the flags and build arguments
var qemuArgs []string
qemuArgs = append(qemuArgs, "-smp", fmt.Sprintf("%d", config.CPUs))
qemuArgs = append(qemuArgs, "-m", fmt.Sprintf("%d", config.Memory))
qemuArgs = append(qemuArgs, "-uuid", config.UUID.String())
// Need to specify the vcpu type when running qemu on arm64 platform, for security reason,
// the vcpu should be "host" instead of other names such as "cortex-a53"...
if config.Arch == "aarch64" {
if runtime.GOARCH == "arm64" {
qemuArgs = append(qemuArgs, "-cpu", "host")
} else {
qemuArgs = append(qemuArgs, "-cpu", "cortex-a57")
}
}
// goArch is the GOARCH equivalent of config.Arch
var goArch string
switch config.Arch {
case "s390x":
goArch = "s390x"
case "aarch64":
goArch = "arm64"
case "x86_64":
goArch = "amd64"
default:
log.Fatalf("%s is an unsupported architecture.", config.Arch)
}
if goArch != runtime.GOARCH {
log.Infof("Disable acceleration as %s != %s", config.Arch, runtime.GOARCH)
config.Accel = ""
}
if config.Accel != "" {
switch config.Arch {
case "s390x":
qemuArgs = append(qemuArgs, "-machine", fmt.Sprintf("s390-ccw-virtio,accel=%s", config.Accel))
case "aarch64":
gic := ""
// VCPU supports less PA bits (36) than requested by the memory map (40)
highmem := "highmem=off,"
if runtime.GOOS == "linux" {
// gic-version=host requires KVM, which implies Linux
gic = "gic_version=host,"
highmem = ""
}
qemuArgs = append(qemuArgs, "-machine", fmt.Sprintf("virt,%s%saccel=%s", gic, highmem, config.Accel))
default:
qemuArgs = append(qemuArgs, "-machine", fmt.Sprintf("q35,accel=%s", config.Accel))
}
} else {
switch config.Arch {
case "s390x":
qemuArgs = append(qemuArgs, "-machine", "s390-ccw-virtio")
case "aarch64":
qemuArgs = append(qemuArgs, "-machine", "virt")
default:
qemuArgs = append(qemuArgs, "-machine", "q35")
}
}
// rng-random does not work on macOS
// Temporarily disable it until fixed upstream.
if runtime.GOOS != "darwin" {
rng := "rng-random,id=rng0"
if runtime.GOOS == "linux" {
rng = rng + ",filename=/dev/urandom"
}
if config.Arch == "s390x" {
qemuArgs = append(qemuArgs, "-object", rng, "-device", "virtio-rng-ccw,rng=rng0")
} else {
qemuArgs = append(qemuArgs, "-object", rng, "-device", "virtio-rng-pci,rng=rng0")
}
}
var lastDisk int
for i, d := range config.Disks {
index := i
if d.Format != "" {
qemuArgs = append(qemuArgs, "-drive", "file="+d.Path+",format="+d.Format+",index="+strconv.Itoa(index)+",media=disk")
} else {
qemuArgs = append(qemuArgs, "-drive", "file="+d.Path+",index="+strconv.Itoa(index)+",media=disk")
}
lastDisk = index
}
// Ensure CDROMs start from at least hdc
if lastDisk < 2 {
lastDisk = 2
}
if config.NetdevConfig == "" {
qemuArgs = append(qemuArgs, "-net", "none")
} else {
mac := generateMAC()
if config.Arch == "s390x" {
qemuArgs = append(qemuArgs, "-device", "virtio-net-ccw,netdev=t0,mac="+mac.String())
} else {
qemuArgs = append(qemuArgs, "-device", "virtio-net-pci,netdev=t0,mac="+mac.String())
}
forwardings, err := buildQemuForwardings(config.PublishedPorts)
if err != nil {
log.Error(err)
}
qemuArgs = append(qemuArgs, "-netdev", config.NetdevConfig+forwardings)
}
if config.GUI != true {
qemuArgs = append(qemuArgs, "-nographic")
}
if config.USB == true {
qemuArgs = append(qemuArgs, "-usb")
}
for _, d := range config.Devices {
qemuArgs = append(qemuArgs, "-device", d)
}
return config, qemuArgs
}
func discoverBinaries(config QemuConfig) (QemuConfig, error) {
if config.QemuImgPath != "" {
return config, nil
}
qemuBinPath := "qemu-system-" + config.Arch
qemuImgPath := "qemu-img"
var err error
config.QemuBinPath, err = exec.LookPath(qemuBinPath)
if err != nil {
return config, fmt.Errorf("Unable to find %s within the $PATH", qemuBinPath)
}
config.QemuImgPath, err = exec.LookPath(qemuImgPath)
if err != nil {
return config, fmt.Errorf("Unable to find %s within the $PATH", qemuImgPath)
}
return config, nil
}
func buildQemuForwardings(publishFlags MultipleFlag) (string, error) {
if len(publishFlags) == 0 {
return "", nil
}
var forwardings string
var publishedPorts []PublishedPort
for _, publish := range publishFlags {
p, err := NewPublishedPort(publish)
if err != nil {
return "", err
log.Fatal(err)
}
hostPort := p.Host
guestPort := p.Guest
forwardings = fmt.Sprintf("%s,hostfwd=%s::%d-:%d", forwardings, p.Protocol, hostPort, guestPort)
publishedPorts = append(publishedPorts, p)
}
return forwardings, nil
}
func buildDockerForwardings(publishedPorts []string) ([]string, error) {
pmap := []string{}
for _, port := range publishedPorts {
s, err := NewPublishedPort(port)
if err != nil {
return nil, err
}
pmap = append(pmap, "-p", fmt.Sprintf("%d:%d/%s", s.Host, s.Guest, s.Protocol))
opts := []qemu.Option{
qemu.WithDisks(disks...),
qemu.WithAccel(accel),
qemu.WithArch(arch),
qemu.WithCPUs(cpus),
qemu.WithMemory(mem),
qemu.WithNetworking(networking),
qemu.WithStdin(os.Stdin),
qemu.WithStdout(os.Stdout),
qemu.WithStderr(os.Stderr),
qemu.WithBios(bios),
}
if enableGUI {
opts = append(opts, qemu.WithGUI())
}
if qemuDetached {
opts = append(opts, qemu.WithDetached())
}
if err := qemu.Run(cmd.Context(), path, opts...); err != nil {
log.Fatal(err)
}
return pmap, nil
}
// QemuConfig contains the config for Qemu
type QemuConfig struct {
Path string
GUI bool
Disks Disks
FWPath string
Arch string
CPUs uint
Memory uint
Accel string
Detached bool
QemuBinPath string
QemuImgPath string
PublishedPorts []string
NetdevConfig string
UUID uuid.UUID
USB bool
Devices []string
}
func haveKVM() bool {
_, err := os.Stat("/dev/kvm")
return !os.IsNotExist(err)
}
func generateMAC() net.HardwareAddr {
mac := make([]byte, 6)
n, err := rand.Read(mac)
if err != nil {
log.WithError(err).Fatal("failed to generate random mac address")
}
if n != 6 {
log.WithError(err).Fatalf("generated %d bytes for random mac address", n)
}
mac[0] &^= 0x01 // Clear multicast bit
mac[0] |= 0x2 // Set locally administered bit
return net.HardwareAddr(mac)
}

Binary file not shown.

View File

@ -1,4 +1,5 @@
//go:generate env GOOS=linux GOARCH=amd64 go build -o sparsecat-linux-amd64 github.com/svenwiltink/sparsecat/cmd/sparsecat
//go:generate env GOOS=linux GOARCH=arm64 go build -o sparsecat-linux-arm64 github.com/svenwiltink/sparsecat/cmd/sparsecat
// Copyright 2022 Linka Cloud All rights reserved.
//
@ -18,23 +19,36 @@ package run
import (
"bufio"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
"go.linka.cloud/d2vm/pkg/qemu"
)
//go:embed sparsecat-linux-amd64
var sparsecatBinary []byte
var sparsecatAmdBinary []byte
//go:embed sparsecat-linux-arm64
var sparsecatArmBinary []byte
func Sparsecat(arch string) ([]byte, error) {
switch arch {
case "amd64":
return sparsecatAmdBinary, nil
case "arm64":
return sparsecatArmBinary, nil
default:
return nil, fmt.Errorf("unsupported architecture: %s", arch)
}
}
// Handle flags with multiple occurrences
type MultipleFlag []string
@ -191,15 +205,8 @@ func ConvertMBtoGB(i int) int {
return (i + (1024 - i%1024)) / 1024
}
// DiskConfig is the config for a disk
type DiskConfig struct {
Path string
Size int
Format string
}
// Disks is the type for a list of DiskConfig
type Disks []DiskConfig
type Disks []qemu.Disk
func (l *Disks) String() string {
return fmt.Sprint(*l)
@ -207,7 +214,7 @@ func (l *Disks) String() string {
// Set is used by flag to configure value from CLI
func (l *Disks) Set(value string) error {
d := DiskConfig{}
d := qemu.Disk{}
s := strings.Split(value, ",")
for _, p := range s {
c := strings.SplitN(p, "=", 2)
@ -340,23 +347,3 @@ func (p *pw) Progress() int {
defer p.mu.RUnlock()
return p.total
}
type QemuInfo struct {
VirtualSize int `json:"virtual-size"`
Filename string `json:"filename"`
Format string `json:"format"`
ActualSize int `json:"actual-size"`
DirtyFlag bool `json:"dirty-flag"`
}
func ImgInfo(ctx context.Context, path string) (*QemuInfo, error) {
o, err := exec.CommandContext(ctx, "qemu-img", "info", path, "--output", "json").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("%v: %s", err, string(o))
}
var i QemuInfo
if err := json.Unmarshal(o, &i); err != nil {
return nil, err
}
return &i, nil
}

View File

@ -19,7 +19,7 @@ import (
"github.com/spf13/cobra"
"go.linka.cloud/console"
exec2 "go.linka.cloud/d2vm/pkg/exec"
"go.linka.cloud/d2vm/pkg/qemu_img"
)
var (
@ -74,7 +74,7 @@ func vbox(ctx context.Context, path string) error {
if err != nil {
return fmt.Errorf("Cannot find management binary %s: %v", vboxmanageFlag, err)
}
i, err := ImgInfo(ctx, path)
i, err := qemu_img.Info(ctx, path)
if err != nil {
return fmt.Errorf("failed to get image info: %v", err)
}
@ -86,14 +86,14 @@ func vbox(ctx context.Context, path string) error {
}
defer os.RemoveAll(vdi)
logrus.Infof("converting image to raw: %s", vdi)
if err := exec2.Run(ctx, "qemu-img", "convert", "-O", "vdi", path, vdi); err != nil {
if err := qemu_img.Convert(ctx, "vdi", path, vdi); err != nil {
return err
}
path = vdi
}
// remove machine in case it already exists
cleanup(vboxmanage, name)
cleanup(vboxmanage, name, false)
_, out, err := manage(vboxmanage, "createvm", "--name", name, "--register")
if err != nil {
@ -273,22 +273,26 @@ func vbox(ctx context.Context, path string) error {
return <-errs
}
func cleanup(vboxmanage string, name string) {
if _, _, err := manage(vboxmanage, "controlvm", name, "poweroff"); err != nil {
func cleanup(vboxmanage string, name string, logErrs ...bool) {
logErr := true
if len(logErrs) > 0 {
logErr = logErrs[0]
}
if _, _, err := manage(vboxmanage, "controlvm", name, "poweroff"); err != nil && logErr {
log.Errorf("controlvm poweroff error: %v", err)
}
_, out, err := manage(vboxmanage, "storageattach", name, "--storagectl", "IDE Controller", "--port", "1", "--device", "0", "--type", "hdd", "--medium", "emptydrive")
if err != nil {
if err != nil && logErr {
log.Errorf("storageattach error: %v\n%s", err, out)
}
for i := range disks {
id := strconv.Itoa(i)
_, out, err := manage(vboxmanage, "storageattach", name, "--storagectl", "SATA", "--port", "0", "--device", id, "--type", "hdd", "--medium", "emptydrive")
if err != nil {
if err != nil && logErr {
log.Errorf("storageattach error: %v\n%s", err, out)
}
}
if _, out, err = manage(vboxmanage, "unregistervm", name, "--delete"); err != nil {
if _, out, err = manage(vboxmanage, "unregistervm", name, "--delete"); err != nil && logErr {
log.Errorf("unregistervm error: %v\n%s", err, out)
}
}

89
config.go Normal file
View File

@ -0,0 +1,89 @@
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"fmt"
"strings"
)
var (
configUbuntu = Config{
Kernel: "/boot/vmlinuz",
Initrd: "/boot/initrd.img",
}
configDebian = Config{
Kernel: "/boot/vmlinuz",
Initrd: "/boot/initrd.img",
}
configAlpine = Config{
Kernel: "/boot/vmlinuz-virt",
Initrd: "/boot/initramfs-virt",
}
configCentOS = Config{
Kernel: "/boot/vmlinuz",
Initrd: "/boot/initrd.img",
}
)
type Root interface {
String() string
}
type RootUUID string
func (r RootUUID) String() string {
return "UUID=" + string(r)
}
type RootPath string
func (r RootPath) String() string {
return string(r)
}
type Config struct {
Kernel string
Initrd string
}
func (c Config) Cmdline(root Root, args ...string) string {
var r string
if root != nil {
r = fmt.Sprintf("root=%s", root.String())
}
return fmt.Sprintf("ro initrd=%s %s net.ifnames=0 rootfstype=ext4 console=tty0 console=ttyS0,115200n8 %s", c.Initrd, r, strings.Join(args, " "))
}
func (r OSRelease) Config() (Config, error) {
switch r.ID {
case ReleaseUbuntu:
if r.VersionID < "20.04" {
return configDebian, nil
}
return configUbuntu, nil
case ReleaseDebian:
return configDebian, nil
case ReleaseKali:
return configDebian, nil
case ReleaseAlpine:
return configAlpine, nil
case ReleaseCentOS:
return configCentOS, nil
default:
return Config{}, fmt.Errorf("%s: distribution not supported", r.ID)
}
}

157
config_test.go Normal file
View File

@ -0,0 +1,157 @@
// Copyright 2022 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"go.linka.cloud/d2vm/pkg/docker"
"go.linka.cloud/d2vm/pkg/exec"
)
func testConfig(t *testing.T, ctx context.Context, name, img string, config Config, luks, grubBIOS, grubEFI bool) {
require.NoError(t, docker.Pull(ctx, Arch, img))
tmpPath := filepath.Join(os.TempDir(), "d2vm-tests", strings.NewReplacer(":", "-", ".", "-").Replace(name))
require.NoError(t, os.MkdirAll(tmpPath, 0755))
defer os.RemoveAll(tmpPath)
logrus.Infof("inspecting image %s", img)
r, err := FetchDockerImageOSRelease(ctx, img)
require.NoError(t, err)
defer docker.Remove(ctx, img)
if !r.SupportsLUKS() && luks {
t.Skipf("LUKS not supported for %s", r.Version)
}
d, err := NewDockerfile(r, img, "root", "", luks, grubBIOS, grubEFI)
require.NoError(t, err)
logrus.Infof("docker image based on %s", d.Release.Name)
p := filepath.Join(tmpPath, docker.FormatImgName(name))
dir := filepath.Dir(p)
f, err := os.Create(p)
require.NoError(t, err)
defer f.Close()
require.NoError(t, d.Render(f))
imgUUID := uuid.New().String()
logrus.Infof("building kernel enabled image")
require.NoError(t, docker.Build(ctx, false, imgUUID, p, dir, Arch))
defer docker.Remove(ctx, imgUUID)
// we don't need to test the kernel location if grub is enabled
if grubBIOS || grubEFI {
return
}
require.NoError(t, docker.RunAndRemove(ctx, imgUUID, "test", "-f", config.Kernel))
require.NoError(t, docker.RunAndRemove(ctx, imgUUID, "test", "-f", config.Initrd))
}
func TestConfig(t *testing.T) {
t.Parallel()
tests := []struct {
image string
config Config
}{
{
image: "ubuntu:18.04",
config: configDebian,
},
{
image: "ubuntu:20.04",
config: configUbuntu,
},
{
image: "ubuntu:22.04",
config: configUbuntu,
},
{
image: "ubuntu:latest",
config: configUbuntu,
},
{
image: "debian:9",
config: configDebian,
},
{
image: "debian:10",
config: configDebian,
},
{
image: "debian:11",
config: configDebian,
},
{
image: "debian:latest",
config: configDebian,
},
{
image: "kalilinux/kali-rolling:latest",
config: configDebian,
},
{
image: "alpine:3.16",
config: configAlpine,
},
{
image: "alpine",
config: configAlpine,
},
{
image: "centos:8",
config: configCentOS,
},
{
image: "centos:latest",
config: configCentOS,
},
}
exec.SetDebug(true)
names := []string{"luks", "grub-bios", "grub-efi"}
bools := []bool{false, true}
for _, test := range tests {
test := test
t.Run(test.image, func(t *testing.T) {
t.Parallel()
for _, luks := range bools {
for _, grubBIOS := range bools {
for _, grubEFI := range bools {
luks := luks
grubBIOS := grubBIOS
grubEFI := grubEFI
n := []string{test.image}
for i, v := range []bool{luks, grubBIOS, grubEFI} {
if v {
n = append(n, names[i])
}
}
name := strings.Join(n, "-")
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
testConfig(t, ctx, name, test.image, test.config, luks, grubBIOS, grubEFI)
})
}
}
}
})
}
}

67
container_disk.go Normal file
View File

@ -0,0 +1,67 @@
// Copyright 2022 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"go.linka.cloud/d2vm/pkg/docker"
"go.linka.cloud/d2vm/pkg/qemu_img"
)
const (
// https://kubevirt.io/user-guide/virtual_machines/disks_and_volumes/#containerdisk-workflow-example
uid = 107
containerDiskDockerfile = `FROM scratch
ADD --chown=%[1]d:%[1]d %[2]s /disk/
`
)
func MakeContainerDisk(ctx context.Context, path string, tag string, platform string) error {
tmpPath := filepath.Join(os.TempDir(), "d2vm", uuid.New().String())
if err := os.MkdirAll(tmpPath, os.ModePerm); err != nil {
return err
}
defer func() {
if err := os.RemoveAll(tmpPath); err != nil {
logrus.Errorf("failed to remove tmp dir %s: %v", tmpPath, err)
}
}()
if _, err := os.Stat(path); err != nil {
return err
}
// convert may not be needed, but this will also copy the file in the tmp dir
qcow2 := filepath.Join(tmpPath, "disk.qcow2")
if err := qemu_img.Convert(ctx, "qcow2", path, qcow2); err != nil {
return err
}
disk := filepath.Base(qcow2)
dockerfileContent := fmt.Sprintf(containerDiskDockerfile, uid, disk)
dockerfile := filepath.Join(tmpPath, "Dockerfile")
if err := os.WriteFile(dockerfile, []byte(dockerfileContent), os.ModePerm); err != nil {
return fmt.Errorf("failed to write dockerfile: %w", err)
}
if err := docker.Build(ctx, false, tag, dockerfile, tmpPath, platform); err != nil {
return fmt.Errorf("failed to build container disk: %w", err)
}
return nil
}

View File

@ -41,12 +41,17 @@ func Convert(ctx context.Context, img string, opts ...ConvertOption) error {
defer os.RemoveAll(tmpPath)
logrus.Infof("inspecting image %s", img)
r, err := FetchDockerImageOSRelease(ctx, img, tmpPath)
r, err := FetchDockerImageOSRelease(ctx, img)
if err != nil {
return err
}
if o.luksPassword != "" && !r.SupportsLUKS() {
return fmt.Errorf("luks is not supported for %s %s", r.Name, r.Version)
}
if !o.raw {
d, err := NewDockerfile(r, img, o.password, o.networkManager)
d, err := NewDockerfile(r, img, o.password, o.networkManager, o.luksPassword != "", o.hasGrubBIOS(), o.hasGrubEFI())
if err != nil {
return err
}
@ -62,16 +67,20 @@ func Convert(ctx context.Context, img string, opts ...ConvertOption) error {
return err
}
logrus.Infof("building kernel enabled image")
if err := docker.Build(ctx, imgUUID, p, dir); err != nil {
if err := docker.Build(ctx, o.pull, imgUUID, p, dir, o.platform); err != nil {
return err
}
defer docker.Remove(ctx, imgUUID)
if !o.keepCache {
defer docker.Remove(ctx, imgUUID)
}
} else {
// for raw images, we just tag the image with the uuid
if err := docker.Tag(ctx, img, imgUUID); err != nil {
return err
}
defer docker.Remove(ctx, imgUUID)
if !o.keepCache {
defer docker.Remove(ctx, imgUUID)
}
}
logrus.Infof("creating vm image")
@ -79,7 +88,7 @@ func Convert(ctx context.Context, img string, opts ...ConvertOption) error {
if format == "" {
format = "raw"
}
b, err := NewBuilder(ctx, tmpPath, imgUUID, "", o.size, r, format, o.cmdLineExtra)
b, err := NewBuilder(ctx, tmpPath, imgUUID, "", o.size, r, format, o.cmdLineExtra, o.splitBoot, o.bootFS, o.bootSize, o.luksPassword, o.bootLoader, o.platform)
if err != nil {
return err
}

View File

@ -17,15 +17,34 @@ package d2vm
type ConvertOption func(o *convertOptions)
type convertOptions struct {
size int64
size uint64
password string
output string
cmdLineExtra string
networkManager NetworkManager
bootLoader string
raw bool
splitBoot bool
bootSize uint64
bootFS BootFS
luksPassword string
keepCache bool
platform string
pull bool
}
func WithSize(size int64) ConvertOption {
func (o *convertOptions) hasGrubBIOS() bool {
return o.bootLoader == "grub" || o.bootLoader == "grub-bios"
}
func (o *convertOptions) hasGrubEFI() bool {
return o.bootLoader == "grub" || o.bootLoader == "grub-efi"
}
func WithSize(size uint64) ConvertOption {
return func(o *convertOptions) {
o.size = size
}
@ -55,8 +74,56 @@ func WithNetworkManager(networkManager NetworkManager) ConvertOption {
}
}
func WithBootLoader(bootLoader string) ConvertOption {
return func(o *convertOptions) {
o.bootLoader = bootLoader
}
}
func WithRaw(raw bool) ConvertOption {
return func(o *convertOptions) {
o.raw = raw
}
}
func WithSplitBoot(b bool) ConvertOption {
return func(o *convertOptions) {
o.splitBoot = b
}
}
func WithBootSize(bootSize uint64) ConvertOption {
return func(o *convertOptions) {
o.bootSize = bootSize
}
}
func WithBootFS(bootFS BootFS) ConvertOption {
return func(o *convertOptions) {
o.bootFS = bootFS
}
}
func WithLuksPassword(password string) ConvertOption {
return func(o *convertOptions) {
o.luksPassword = password
}
}
func WithKeepCache(b bool) ConvertOption {
return func(o *convertOptions) {
o.keepCache = b
}
}
func WithPlatform(platform string) ConvertOption {
return func(o *convertOptions) {
o.platform = platform
}
}
func WithPull(b bool) ConvertOption {
return func(o *convertOptions) {
o.pull = b
}
}

View File

@ -24,11 +24,11 @@ import (
"text/template"
"github.com/google/go-containerregistry/cmd/crane/cmd"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/crane"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/daemon"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"go.linka.cloud/d2vm/pkg/docker"
"go.linka.cloud/d2vm/pkg/exec"
)
@ -81,15 +81,16 @@ func (i DockerImage) AsRunScript(w io.Writer) error {
}
func NewImage(ctx context.Context, tag string, imageTmpPath string) (*image, error) {
ref, err := name.ParseReference(tag)
if err != nil {
if err := os.MkdirAll(imageTmpPath, os.ModePerm); err != nil {
return nil, err
}
img, err := daemon.Image(ref)
if err != nil {
// save the image to a tar file to avoid loading it in memory
tar := filepath.Join(imageTmpPath, "img.layers.tar")
if err := docker.ImageSave(ctx, tag, tar); err != nil {
return nil, err
}
if err := os.MkdirAll(imageTmpPath, perm); err != nil {
img, err := crane.Load(tar)
if err != nil {
return nil, err
}
i := &image{
@ -109,10 +110,9 @@ type image struct {
}
func (i image) Flatten(ctx context.Context, out string) error {
if err := os.MkdirAll(out, perm); err != nil {
if err := os.MkdirAll(out, os.ModePerm); err != nil {
return err
}
tar := filepath.Join(i.dir, "img.tar")
f, err := os.Create(tar)
if err != nil {

View File

@ -134,13 +134,13 @@ RUN rm -rf /etc/apk
)
exec.SetDebug(true)
tmp := filepath.Join(os.TempDir(), "d2vm-tests", "image-flatten")
require.NoError(t, os.MkdirAll(tmp, perm))
require.NoError(t, os.MkdirAll(tmp, os.ModePerm))
defer os.RemoveAll(tmp)
require.NoError(t, os.WriteFile(filepath.Join(tmp, "hostname"), []byte("d2vm-flatten-test"), perm))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "resolv.conf"), []byte("nameserver 8.8.8.8"), perm))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "Dockerfile"), []byte(dockerfile), perm))
require.NoError(t, docker.Build(ctx, img, "", tmp))
require.NoError(t, docker.Build(ctx, false, img, "", tmp, "linux/amd64"))
defer docker.Remove(ctx, img)
imgTmp := filepath.Join(tmp, "image")

View File

@ -64,23 +64,30 @@ type Dockerfile struct {
Password string
Release OSRelease
NetworkManager NetworkManager
Luks bool
GrubBIOS bool
GrubEFI bool
tmpl *template.Template
}
func (d Dockerfile) Grub() bool {
return d.GrubBIOS || d.GrubEFI
}
func (d Dockerfile) Render(w io.Writer) error {
return d.tmpl.Execute(w, d)
}
func NewDockerfile(release OSRelease, img, password string, networkManager NetworkManager) (Dockerfile, error) {
if password == "" {
password = "root"
}
d := Dockerfile{Release: release, Image: img, Password: password, NetworkManager: networkManager}
func NewDockerfile(release OSRelease, img, password string, networkManager NetworkManager, luks, grubBIOS, grubEFI bool) (Dockerfile, error) {
d := Dockerfile{Release: release, Image: img, Password: password, NetworkManager: networkManager, Luks: luks, GrubBIOS: grubBIOS, GrubEFI: grubEFI}
var net NetworkManager
switch release.ID {
case ReleaseDebian:
d.tmpl = debianDockerfileTemplate
net = NetworkManagerIfupdown2
case ReleaseKali:
d.tmpl = debianDockerfileTemplate
net = NetworkManagerIfupdown2
case ReleaseUbuntu:
d.tmpl = ubuntuDockerfileTemplate
net = NetworkManagerNetplan

BIN
docs/.DS_Store vendored

Binary file not shown.

1
docs/CNAME Normal file
View File

@ -0,0 +1 @@
d2vm.linka.cloud

View File

@ -6,7 +6,7 @@
```
-h, --help help for d2vm
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -10,21 +10,31 @@ d2vm build [context directory] [flags]
```
--append-to-cmdline string Extra kernel cmdline arguments to append to the generated one
--boot-fs string Filesystem to use for the boot partition, ext4 or fat32
--boot-size uint Size of the boot partition in MB (default 100)
--bootloader string Bootloader to use: syslinux, grub, grub-bios, grub-efi, defaults to syslinux on amd64 and grub-efi on arm64
--build-arg stringArray Set build-time variables
-f, --file string Name of the Dockerfile
--force Override output image
--force Override output qcow2 image
-h, --help help for build
--keep-cache Keep the images after the build
--luks-password string Password to use for the LUKS encrypted root partition. If not set, the root partition will not be encrypted
--network-manager string Network manager to use for the image: none, netplan, ifupdown
-o, --output string The output image, the extension determine the image format, raw will be used if none. Supported formats: qcow2 qed raw vdi vhd vmdk (default "disk0.qcow2")
-p, --password string Root user password (default "root")
-o, --output string The output image, the extension determine the image format, raw will be used if none. Supported formats: qcow2 qed raw vdi vhd vhd vhdx vmdk (default "disk0.qcow2")
-p, --password string Optional root user password
--platform string Platform to use for the container disk image, linux/arm64 and linux/arm64 are supported (default "linux/amd64")
--pull Always pull docker image
--push Push the container disk image to the registry
--raw Just convert the container to virtual machine image without installing anything more
-s, --size string The output image size (default "10G")
--split-boot Split the boot partition from the root partition
-t, --tag string Container disk Docker image tag
```
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -17,7 +17,7 @@ See each sub-command's help for details on how to use the generated script.
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -21,7 +21,7 @@ To load completions for every new session, execute once:
#### macOS:
d2vm completion bash > /usr/local/etc/bash_completion.d/d2vm
d2vm completion bash > $(brew --prefix)/etc/bash_completion.d/d2vm
You will need to start a new shell for this setup to take effect.
@ -40,7 +40,7 @@ d2vm completion bash
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -31,7 +31,7 @@ d2vm completion fish [flags]
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -28,7 +28,7 @@ d2vm completion powershell [flags]
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -11,6 +11,10 @@ to enable it. You can execute the following once:
echo "autoload -U compinit; compinit" >> ~/.zshrc
To load completions in your current shell session:
source <(d2vm completion zsh)
To load completions for every new session, execute once:
#### Linux:
@ -19,7 +23,7 @@ To load completions for every new session, execute once:
#### macOS:
d2vm completion zsh > /usr/local/share/zsh/site-functions/_d2vm
d2vm completion zsh > $(brew --prefix)/share/zsh/site-functions/_d2vm
You will need to start a new shell for this setup to take effect.
@ -38,7 +42,7 @@ d2vm completion zsh [flags]
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -10,20 +10,29 @@ d2vm convert [docker image] [flags]
```
--append-to-cmdline string Extra kernel cmdline arguments to append to the generated one
-f, --force Override output qcow2 image
--boot-fs string Filesystem to use for the boot partition, ext4 or fat32
--boot-size uint Size of the boot partition in MB (default 100)
--bootloader string Bootloader to use: syslinux, grub, grub-bios, grub-efi, defaults to syslinux on amd64 and grub-efi on arm64
--force Override output qcow2 image
-h, --help help for convert
--keep-cache Keep the images after the build
--luks-password string Password to use for the LUKS encrypted root partition. If not set, the root partition will not be encrypted
--network-manager string Network manager to use for the image: none, netplan, ifupdown
-o, --output string The output image, the extension determine the image format, raw will be used if none. Supported formats: qcow2 qed raw vdi vhd vmdk (default "disk0.qcow2")
-p, --password string The Root user password (default "root")
-o, --output string The output image, the extension determine the image format, raw will be used if none. Supported formats: qcow2 qed raw vdi vhd vhd vhdx vmdk (default "disk0.qcow2")
-p, --password string Optional root user password
--platform string Platform to use for the container disk image, linux/arm64 and linux/arm64 are supported (default "linux/amd64")
--pull Always pull docker image
--push Push the container disk image to the registry
--raw Just convert the container to virtual machine image without installing anything more
-s, --size string The output image size (default "10G")
--split-boot Split the boot partition from the root partition
-t, --tag string Container disk Docker image tag
```
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -11,7 +11,7 @@ Run the virtual machine image
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -9,18 +9,20 @@ d2vm run hetzner [options] image-path [flags]
### Options
```
-h, --help help for hetzner
-n, --name string d2vm server name (default "d2vm")
--rm remove server when done
-i, --ssh-key string d2vm image identity key
--token string Hetzner Cloud API token [$HETZNER_TOKEN]
-u, --user string d2vm image ssh user (default "root")
-h, --help help for hetzner
-l, --location string d2vm server location (default "hel1-dc2")
-n, --name string d2vm server name (default "d2vm")
--rm remove server when done
-i, --ssh-key string d2vm image identity key
--token string Hetzner Cloud API token [$HETZNER_TOKEN]
-t, --type string d2vm server type (default "cx11")
-u, --user string d2vm image ssh user (default "root")
```
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -9,12 +9,11 @@ d2vm run qemu [options] [image-path] [flags]
### Options
```
--accel string Choose acceleration mode. Use 'tcg' to disable it. (default "hvf:tcg")
--accel string Choose acceleration mode. Use 'tcg' to disable it. (default "kvm:tcg")
--arch string Type of architecture to use, e.g. x86_64, aarch64, s390x (default "x86_64")
--bios string Path to the optional bios binary
--cpus uint Number of CPUs (default 1)
--data string String of metadata to pass to VM; error to specify both -data and -data-file
--detached Set qemu container to run in the background
--device multiple-flag Add USB host device(s). Format driver[,prop=value][,...] -- add device, like -device on the qemu command line. (default A multiple flag is a type of flag that can be repeated any number of times)
--disk disk Disk config, may be repeated. [file=]path[,size=1G][,format=qcow2] (default [])
--gui Set qemu to use video output instead of stdio
-h, --help help for qemu
@ -22,13 +21,12 @@ d2vm run qemu [options] [image-path] [flags]
--networking string Networking mode. Valid options are 'default', 'user', 'bridge[,name]', tap[,name] and 'none'. 'user' uses QEMUs userspace networking. 'bridge' connects to a preexisting bridge. 'tap' uses a prexisting tap device. 'none' disables networking.` (default "user")
--publish multiple-flag Publish a vm's port(s) to the host (default []) (default A multiple flag is a type of flag that can be repeated any number of times)
--qemu string Path to the qemu binary (otherwise look in $PATH)
--usb Enable USB controller
```
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -22,7 +22,7 @@ d2vm run vbox [options] image-path [flags]
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

View File

@ -15,7 +15,7 @@ d2vm version [flags]
### Options inherited from parent commands
```
-t, --time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
--time string Enable formated timed output, valide formats: 'relative (rel | r)', 'full (f)' (default "none")
-v, --verbose Enable Verbose output
```

211
e2e/e2e_test.go Normal file
View File

@ -0,0 +1,211 @@
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package e2e
import (
"bufio"
"bytes"
"context"
"flag"
"io"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
require2 "github.com/stretchr/testify/require"
"go.linka.cloud/d2vm"
"go.linka.cloud/d2vm/pkg/docker"
"go.linka.cloud/d2vm/pkg/qemu"
)
type test struct {
name string
args []string
efi bool
}
type img struct {
name string
luks string
}
var (
images = []img{
{name: "alpine:3.17", luks: "Enter passphrase for /dev/sda2:"},
{name: "ubuntu:20.04", luks: "Please unlock disk root:"},
{name: "ubuntu:22.04", luks: "Please unlock disk root:"},
{name: "debian:10", luks: "Please unlock disk root:"},
{name: "debian:11", luks: "Please unlock disk root:"},
{name: "centos:8", luks: "Please enter passphrase for disk"},
}
imgNames = func() []string {
var imgs []string
for _, img := range images {
imgs = append(imgs, img.name)
}
return imgs
}()
imgs = flag.String("images", "", "comma separated list of images to test, must be one of: "+strings.Join(imgNames, ","))
)
func TestConvert(t *testing.T) {
require := require2.New(t)
tests := []test{
{
name: "single-partition",
},
{
name: "split-boot",
args: []string{"--split-boot"},
},
{
name: "fat32",
args: []string{"--split-boot", "--boot-fs=fat32"},
},
{
name: "luks",
args: []string{"--luks-password=root"},
},
{
name: "grub",
args: []string{"--bootloader=grub"},
efi: true,
},
{
name: "grub-luks",
args: []string{"--bootloader=grub", "--luks-password=root"},
efi: true,
},
}
var testImgs []img
imgs:
for _, v := range strings.Split(*imgs, ",") {
for _, img := range images {
if img.name == v {
testImgs = append(testImgs, img)
continue imgs
}
}
t.Fatalf("invalid image: %q, valid images: %s", v, strings.Join(imgNames, ","))
}
if len(testImgs) == 0 {
testImgs = images
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := filepath.Join("/tmp", "d2vm-e2e", tt.name)
require.NoError(os.MkdirAll(dir, os.ModePerm))
defer os.RemoveAll(dir)
for _, img := range testImgs {
if strings.Contains(img.name, "centos") && tt.efi {
t.Skip("efi not supported for CentOS")
}
t.Run(img.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
require := require2.New(t)
out := filepath.Join(dir, strings.NewReplacer(":", "-", ".", "-").Replace(img.name)+".qcow2")
if _, err := os.Stat(out); err == nil {
require.NoError(os.Remove(out))
}
require.NoError(docker.RunD2VM(ctx, d2vm.Image, d2vm.Version, dir, dir, "convert", append([]string{"-p", "root", "-o", "/out/" + filepath.Base(out), "-v", "--keep-cache", img.name}, tt.args...)...))
inr, inw := io.Pipe()
defer inr.Close()
outr, outw := io.Pipe()
defer outw.Close()
var success atomic.Bool
go func() {
time.AfterFunc(2*time.Minute, cancel)
defer inw.Close()
defer outr.Close()
login := []byte("login:")
password := []byte("Password:")
s := bufio.NewScanner(outr)
s.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if i := bytes.Index(data, []byte(img.luks)); i >= 0 {
return i + len(img.luks), []byte(img.luks), nil
}
if i := bytes.Index(data, login); i >= 0 {
return i + len(login), login, nil
}
if i := bytes.Index(data, password); i >= 0 {
return i + len(password), password, nil
}
if atEOF {
return 0, nil, io.EOF
}
return 0, nil, nil
})
for s.Scan() {
b := s.Bytes()
if bytes.Contains(b, []byte(img.luks)) {
t.Logf("sending luks password")
if _, err := inw.Write([]byte("root\n")); err != nil {
t.Logf("failed to write luks password: %v", err)
cancel()
}
}
if bytes.Contains(b, login) {
t.Logf("sending login")
if _, err := inw.Write([]byte("root\n")); err != nil {
t.Logf("failed to write login: %v", err)
cancel()
}
}
if bytes.Contains(b, password) {
t.Logf("sending password")
if _, err := inw.Write([]byte("root\n")); err != nil {
t.Logf("failed to write password: %v", err)
cancel()
}
time.Sleep(time.Second)
if _, err := inw.Write([]byte("poweroff\n")); err != nil {
t.Logf("failed to write poweroff: %v", err)
cancel()
}
success.Store(true)
return
}
}
if err := s.Err(); err != nil {
t.Logf("failed to scan output: %v", err)
cancel()
}
}()
opts := []qemu.Option{qemu.WithStdin(inr), qemu.WithStdout(io.MultiWriter(outw, os.Stdout)), qemu.WithStderr(io.Discard), qemu.WithMemory(2048), qemu.WithCPUs(2)}
if tt.efi {
opts = append(opts, qemu.WithBios("/usr/share/ovmf/OVMF.fd"))
}
if err := qemu.Run(ctx, out, opts...); err != nil && !success.Load() {
t.Fatalf("failed to run qemu: %v", err)
}
})
}
})
}
}

58
fs.go Normal file
View File

@ -0,0 +1,58 @@
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"fmt"
)
type BootFS string
const (
BootFSExt4 BootFS = "ext4"
BootFSFat32 BootFS = "fat32"
)
func (f BootFS) String() string {
return string(f)
}
func (f BootFS) IsExt() bool {
return f == BootFSExt4
}
func (f BootFS) IsFat() bool {
return f == BootFSFat32
}
func (f BootFS) IsSupported() bool {
return f.IsExt() || f.IsFat()
}
func (f BootFS) Validate() error {
if !f.IsSupported() {
fmt.Errorf("invalid boot filesystem: %s valid filesystems are: fat32, ext4", f)
}
return nil
}
func (f BootFS) linux() string {
switch f {
case BootFSFat32:
return "vfat"
default:
return "ext4"
}
}

87
go.mod
View File

@ -1,70 +1,69 @@
module go.linka.cloud/d2vm
go 1.17
go 1.20
require (
github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2
github.com/c2h5oh/datasize v0.0.0-20220606134207-859f65c6625b
github.com/dustin/go-humanize v1.0.0
github.com/fatih/color v1.13.0
github.com/google/go-containerregistry v0.8.0
github.com/google/go-containerregistry v0.14.0
github.com/google/uuid v1.3.0
github.com/hetznercloud/hcloud-go v1.35.2
github.com/joho/godotenv v1.4.0
github.com/hetznercloud/hcloud-go v1.50.0
github.com/joho/godotenv v1.5.1
github.com/pkg/sftp v1.10.1
github.com/sirupsen/logrus v1.8.1
github.com/spf13/cobra v1.4.0
github.com/stretchr/testify v1.7.0
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.8.4
github.com/svenwiltink/sparsecat v1.0.0
go.linka.cloud/console v0.0.0-20220910100646-48f9f2b8843b
go.uber.org/multierr v1.8.0
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
go.uber.org/multierr v1.11.0
golang.org/x/crypto v0.11.0
golang.org/x/sys v0.10.0
)
require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.5.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/containerd/containerd v1.5.8 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.10.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/creack/pty v1.1.15 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/docker/cli v20.10.12+incompatible // indirect
github.com/docker/distribution v2.7.1+incompatible // indirect
github.com/docker/docker v20.10.12+incompatible // indirect
github.com/docker/docker-credential-helpers v0.6.4 // indirect
github.com/docker/go-connections v0.4.1-0.20190612165340-fd1b1942c4d5 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.6 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/docker/cli v23.0.4+incompatible // indirect
github.com/docker/distribution v2.8.1+incompatible // indirect
github.com/docker/docker v23.0.4+incompatible // indirect
github.com/docker/docker-credential-helpers v0.7.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.16.5 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2-0.20211117181255-693428a734f5 // indirect
github.com/opencontainers/image-spec v1.1.0-rc2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.11.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.26.0 // indirect
github.com/prometheus/procfs v0.6.0 // indirect
github.com/prometheus/client_golang v1.16.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.10.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/vbatts/tar-split v0.11.2 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
google.golang.org/grpc v1.43.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
github.com/vbatts/tar-split v0.11.3 // indirect
golang.org/x/net v0.12.0 // indirect
golang.org/x/sync v0.2.0 // indirect
golang.org/x/text v0.11.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace (
github.com/prometheus/client_golang => github.com/prometheus/client_golang v1.11.1
golang.org/x/crypto => golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b
google.golang.org/protobuf => google.golang.org/protobuf v1.29.1
)

1366
go.sum

File diff suppressed because it is too large Load Diff

76
grub.go Normal file
View File

@ -0,0 +1,76 @@
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"context"
"fmt"
"github.com/sirupsen/logrus"
)
type grub struct {
*grubCommon
}
func (g grub) Validate(fs BootFS) error {
switch fs {
case BootFSFat32:
return nil
default:
return fmt.Errorf("grub only supports fat32 boot filesystem due to grub-efi")
}
}
func (g grub) Setup(ctx context.Context, dev, root string, cmdline string) error {
logrus.Infof("setting up grub bootloader")
clean, err := g.prepare(ctx, dev, root, cmdline)
if err != nil {
return err
}
defer clean()
if err := g.install(ctx, "--target=x86_64-efi", "--efi-directory=/boot", "--no-nvram", "--removable", "--no-floppy"); err != nil {
return err
}
if err := g.install(ctx, "--target=i386-pc", "--boot-directory=/boot", dev); err != nil {
return err
}
if err := g.mkconfig(ctx); err != nil {
return err
}
return nil
}
type grubProvider struct {
config Config
}
func (g grubProvider) New(c Config, r OSRelease, arch string) (Bootloader, error) {
if arch != "x86_64" {
return nil, fmt.Errorf("grub is only supported for amd64")
}
if r.ID == ReleaseCentOS {
return nil, fmt.Errorf("grub (efi) is not supported for CentOS, use grub-bios instead")
}
return grub{grubCommon: newGrubCommon(c, r)}, nil
}
func (g grubProvider) Name() string {
return "grub"
}
func init() {
RegisterBootloaderProvider(grubProvider{})
}

65
grub_bios.go Normal file
View File

@ -0,0 +1,65 @@
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"context"
"fmt"
"github.com/sirupsen/logrus"
)
type grubBios struct {
*grubCommon
}
func (g grubBios) Validate(_ BootFS) error {
return nil
}
func (g grubBios) Setup(ctx context.Context, dev, root string, cmdline string) error {
logrus.Infof("setting up grub bootloader")
clean, err := g.prepare(ctx, dev, root, cmdline)
if err != nil {
return err
}
defer clean()
if err := g.install(ctx, "--target=i386-pc", "--boot-directory=/boot", dev); err != nil {
return err
}
if err := g.mkconfig(ctx); err != nil {
return err
}
return nil
}
type grubBiosProvider struct {
config Config
}
func (g grubBiosProvider) New(c Config, r OSRelease, arch string) (Bootloader, error) {
if arch != "x86_64" {
return nil, fmt.Errorf("grub-bios is only supported for amd64")
}
return grubBios{grubCommon: newGrubCommon(c, r)}, nil
}
func (g grubBiosProvider) Name() string {
return "grub-bios"
}
func init() {
RegisterBootloaderProvider(grubBiosProvider{})
}

102
grub_common.go Normal file
View File

@ -0,0 +1,102 @@
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/sirupsen/logrus"
"go.linka.cloud/d2vm/pkg/exec"
)
const grubCfg = `GRUB_DEFAULT=0
GRUB_HIDDEN_TIMEOUT=0
GRUB_HIDDEN_TIMEOUT_QUIET=true
GRUB_TIMEOUT=0
GRUB_CMDLINE_LINUX_DEFAULT="%s"
GRUB_CMDLINE_LINUX=""
GRUB_TERMINAL=console
`
type grubCommon struct {
name string
c Config
r OSRelease
root string
dev string
}
func newGrubCommon(c Config, r OSRelease) *grubCommon {
name := "grub"
if r.ID == "centos" {
name = "grub2"
}
return &grubCommon{
name: name,
c: c,
r: r,
}
}
func (g *grubCommon) prepare(ctx context.Context, dev, root, cmdline string) (clean func(), err error) {
g.dev = dev
g.root = root
if err = os.WriteFile(filepath.Join(root, "etc", "default", "grub"), []byte(fmt.Sprintf(grubCfg, cmdline)), perm); err != nil {
return
}
if err = os.MkdirAll(filepath.Join(root, "boot", g.name), os.ModePerm); err != nil {
return
}
mounts := []string{"dev", "proc", "sys"}
var unmounts []string
clean = func() {
for _, v := range unmounts {
if err := exec.Run(ctx, "umount", filepath.Join(root, v)); err != nil {
logrus.Errorf("failed to unmount /%s: %s", v, err)
}
}
}
defer func() {
if err != nil {
clean()
}
}()
for _, v := range mounts {
if err = exec.Run(ctx, "mount", "-o", "bind", "/"+v, filepath.Join(root, v)); err != nil {
return
}
unmounts = append(unmounts, v)
}
return
}
func (g *grubCommon) install(ctx context.Context, args ...string) error {
if g.dev == "" || g.root == "" {
return fmt.Errorf("grubCommon not prepared")
}
args = append([]string{g.root, g.name + "-install"}, args...)
return exec.Run(ctx, "chroot", args...)
}
func (g *grubCommon) mkconfig(ctx context.Context) error {
if g.dev == "" || g.root == "" {
return fmt.Errorf("grubCommon not prepared")
}
return exec.Run(ctx, "chroot", g.root, g.name+"-mkconfig", "-o", "/boot/"+g.name+"/grub.cfg")
}

71
grub_efi.go Normal file
View File

@ -0,0 +1,71 @@
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"context"
"fmt"
"github.com/sirupsen/logrus"
)
type grubEFI struct {
*grubCommon
arch string
}
func (g grubEFI) Validate(fs BootFS) error {
switch fs {
case BootFSFat32:
return nil
default:
return fmt.Errorf("grub-efi only supports fat32 boot filesystem")
}
}
func (g grubEFI) Setup(ctx context.Context, dev, root string, cmdline string) error {
logrus.Infof("setting up grub-efi bootloader")
clean, err := g.prepare(ctx, dev, root, cmdline)
if err != nil {
return err
}
defer clean()
if err := g.install(ctx, "--target="+g.arch+"-efi", "--efi-directory=/boot", "--no-nvram", "--removable", "--no-floppy"); err != nil {
return err
}
if err := g.mkconfig(ctx); err != nil {
return err
}
return nil
}
type grubEFIProvider struct {
config Config
}
func (g grubEFIProvider) New(c Config, r OSRelease, arch string) (Bootloader, error) {
if r.ID == ReleaseCentOS {
return nil, fmt.Errorf("grub-efi is not supported for CentOS, use grub-bios instead")
}
return grubEFI{grubCommon: newGrubCommon(c, r), arch: arch}, nil
}
func (g grubEFIProvider) Name() string {
return "grub-efi"
}
func init() {
RegisterBootloaderProvider(grubEFIProvider{})
}

View File

@ -16,11 +16,8 @@ package d2vm
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"text/template"
"github.com/joho/godotenv"
"github.com/sirupsen/logrus"
@ -34,6 +31,7 @@ const (
ReleaseAlpine Release = "alpine"
ReleaseCentOS Release = "centos"
ReleaseRHEL Release = "rhel"
ReleaseKali Release = "kali"
)
type Release string
@ -44,6 +42,8 @@ func (r Release) Supported() bool {
return true
case ReleaseDebian:
return true
case ReleaseKali:
return true
case ReleaseAlpine:
return true
case ReleaseCentOS:
@ -63,6 +63,31 @@ type OSRelease struct {
VersionCodeName string
}
func (r OSRelease) SupportsLUKS() bool {
switch r.ID {
case ReleaseUbuntu:
return r.VersionID >= "20.04"
case ReleaseDebian:
v, err := strconv.Atoi(r.VersionID)
if err != nil {
logrus.Warnf("%s: failed to parse version id: %v", r.Version, err)
return false
}
return v >= 10
case ReleaseKali:
// TODO: check version
return true
case ReleaseCentOS:
return true
case ReleaseAlpine:
return true
case ReleaseRHEL:
return false
default:
return false
}
}
func ParseOSRelease(s string) (OSRelease, error) {
env, err := godotenv.Parse(strings.NewReader(s))
if err != nil {
@ -78,40 +103,8 @@ func ParseOSRelease(s string) (OSRelease, error) {
return o, nil
}
const (
osReleaseDockerfile = `
FROM {{ . }}
ENTRYPOINT [""]
CMD ["/bin/cat", "/etc/os-release"]
`
)
var (
osReleaseDockerfileTemplate = template.Must(template.New("osrelease.Dockerfile").Parse(osReleaseDockerfile))
)
func FetchDockerImageOSRelease(ctx context.Context, img string, tmpPath string) (OSRelease, error) {
d := filepath.Join(tmpPath, "osrelease.Dockerfile")
f, err := os.Create(d)
if err != nil {
return OSRelease{}, err
}
defer f.Close()
if err := osReleaseDockerfileTemplate.Execute(f, img); err != nil {
return OSRelease{}, err
}
imgTag := fmt.Sprintf("os-release-%s", img)
if err := docker.Cmd(ctx, "image", "build", "-t", imgTag, "-f", d, tmpPath); err != nil {
return OSRelease{}, err
}
defer func() {
if err := docker.Cmd(ctx, "image", "rm", imgTag); err != nil {
logrus.WithError(err).Error("failed to cleanup OSRelease Docker Image")
}
}()
o, _, err := docker.CmdOut(ctx, "run", "--rm", "-i", imgTag)
func FetchDockerImageOSRelease(ctx context.Context, img string) (OSRelease, error) {
o, _, err := docker.CmdOut(ctx, "run", "--rm", "-i", "--entrypoint", "cat", img, "/etc/os-release")
if err != nil {
return OSRelease{}, err
}

51
pgp.pub Normal file
View File

@ -0,0 +1,51 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGLCuWcBEADzbuC8tyB0zPmReCu0Jwvc9tJqErtaYXxizM2XGiYc6iYOheFb
FnSFwlWK4TmtZ0XxIMMMJpIMVy1eEdbv3SBqHYXDWu+FzbEF32zfh/Sp4jzTbAZy
eKdXdcKYShhBNBnvfdQBsKG8J5PJi37w9yX9IVfviPymz1j42w7kvPMt6KM0HC3q
tJzwYLt6yizpY1DkT1lhypK2cgWrbBBTzxiUVHAK1Zxxr+MkSFowW5MHV0nfVWL2
5RU9mJny98YYvUJ7cQ1WaBjDQ9LSwMi+BVfZ87ABsc6NOyxPyEq7g/qwXdWL9QVq
bVFKvj2AaYeVJn17LYG3Ao8FfAt2tUN3FasrvmXnXumkqErwrCkGG5LFuRE11X/2
ntSf2Ra+dp81XHmugGHKdqdridQZPFTHeMQgk5Nyo0nNaQ0dO8w0zvLEqqQlEe4O
lTEK6MRPN2Pq9Rl2dC4YHU6ctnBEIwf98ysK2oIuHEnt8YZALg1jB2a1ANYOy25Z
UHOr2FlhCD6kdVpwGp99NH0lhKRcGl9BFDakYGWkjCqZT3UAZa+qKIp+wfNn75kI
1WjsIg0JFeH57RydlZf0eCelHY90rUKLB0kAlt/mWjoDyFnfrVHVfDh4yb/Gnwts
0cFWmink7fB0OLvzQvNGYPDkGG9tyjmepFm3OdD+wi2x3fPhPnB/o+sXXQARAQAB
tCJsaW5rYS1jbG91ZCA8c2VjdXJpdHlAbGlua2EtY2xvdWQ+iQJOBBMBCgA4FiEE
dRYaVRQmvhJpUDzmgEjKN+QesW0FAmLCuWcCGwMFCwkIBwIGFQoJCAsCBBYCAwEC
HgECF4AACgkQgEjKN+QesW0feQ/9H3HOjEv45em9JrdQu9UqjIG7VJG8En0fy2K2
oSQ65rOCRk1oxet2hay/jIkNfXwhSQ7pAOzs69JnDrXLYS9gZ3gSHRoWXSRe1way
mPIorq0hxaBJ6iJybmgOj4gqFmK+ObwcGUZq8y9hdD1UkQG9zM3+jPOh9Bd9t8ld
S8H4Sew1ZC2vVqh281BxvmJM9w94EuVw+4gJ72Rg74W2TOhK0qEvblHU7UbLI3s5
w+WvoLHQw3V6DmhuTsFWXWY1WXtlsYa5dE3QLWtXqCzt+yL1lw1opxgnP5BTBwjP
v5nZCWw6RC1EJxd2ac5IK8bXLcM0BtmHbVqA7cbeU4jc5TBj0Lu5o1AfUUCbyRMg
i7LDVn9ivCD3mymyGCtNsdj+oad9MwKJMlHNwzWNf2yE8GjxO09RYx/AzlubozCG
qlJvsEQUvUftGDW1adSdT+QRfIIS9pg2nZbMe+U0udrjpV1OGZDPgO7UNxi9u2Kl
JrVZ460K2psJEOBOaHg6Baj3HU1Ac6+UCigXbOx7WD7o3Rj8eNf1bIX/MXPfbBkv
Hh4dUchPuo1ImvU59w/jseuyXdiijCo5b5qeK2227XvXHIy3138x6gzs6JL5TXRp
F+D87+2WDu3yKjch0Sk0t+dKvuwsul/17wzDvMBIBMW96hRJhDVbUfaMPahf6Cfp
Gk3+t5W5Ag0EYsK5ZwEQAMJsn7eADPF9GshQRkzCcSxkeCh1OXWgPTOVEkBwKTeH
TXTEV4seANFHeUrTru3U/uuCsWi8eiwe5HTJKtPANud1iaUuMn7AyU0NteC8Bk9U
duwQXt02nke5jruNYOBm4j5yQYIBfa75ziDLUz7+NeAXIc3DhRM9gtE4N+5L95p+
bPPI077TldUkSLZM2kVIeWiAmZ1zsTg6SDW8wFMBoFfOtkEffZco4gzHlj8vPAc6
jkbgwrH7RZsWBcz2t7l/1AycDNPTElgFxnLxmBG01bNQuXTviNMZ0trNCtk2RdkS
iSLhIc3hMmaxnqy2mDzCVq/DCERo54iadXTJew9Y5jh2cO6V/rTJSnwaIh9AHMor
VO/kWPOzC/XMj1kL27DMwbByzm3619p0FgLPYqMdiWkmGhjVr0kcM+sBcepMliRs
eQy6q47IFm90XyUL9IXMtLWeleQ5/zHjD1CsKc/bSB5lzxLsv/IckYq8c9bbbPA0
bCGosQNQQMZTR/9sJ5VmYOWzDimqvX8l12GvYiEQQFO/lTypYjtE3XxjtXGRIA+4
i1t20Lmy2Scl1WV+LMbhk0i+sEBOzuD5jctQWqX68KYYUqWbiqULsrdHtgodITDG
CrlVvwB9BACb2JVDjbUhY3VDl3yHopmGM0kMZGLUvRaJOnCh2Dc+B/cCt/iiO6UB
ABEBAAGJAjYEGAEKACAWIQR1FhpVFCa+EmlQPOaASMo35B6xbQUCYsK5ZwIbDAAK
CRCASMo35B6xbRuWEACFH9cR36izop9hOu7oEnwLABGC6U4mipTvgKD4wu1SS0U4
NRPzkTm7FGxXy2QPbDOj1/G7dvHc7fGzQimofGRIW/4/GVWRQi2pQJwKTP1KlRid
e9oFG1+MTc6o1ZBkhz1GQbMGxeu9Na0c0DzXGMrsP6G75WSUEX+5srXuJtrxRrBy
E3BICzn5YyWTT2cLgN3AucalL04TIqGiocvi3X/n03CXe2M2mbJFo1y1bl6M/tmR
0fktpVlCDRs6EphMXHsKSTtO6pKPN6M0Lg50vS512xNGyc/tL2aJw/snO0vMDHSU
JDebH4LjV1RmTRnUJxERDCfe8Jh1bsIa4u8DcVmoLyjTMXJrPNUycYIkyfp9mwAX
LjBY2mW6Qyfp3IVdQUS1N8okIk1TiHIZC7DXdXuH7WnfEDDTV9fY91jY835y8stj
lpX0UGjm+npt2Vyth9kIQtgUnnBl76PdjAmCHztEaL1SXiQh6h2DhdkfeVX4GUrF
BE02K7Qy6ERZo7274WLlJOiW5EyyOKVHYrSzRLajsh8xHus270FhfcKRQG+LO8f+
2ecvNRuNZcMiwZDUwSzUkabXk8C9F/3EOcUnHoznh/g//Z17/ZktIuJy4DLLkYIk
jtPL12rsffEIbEo7Ok/ntyVf5rgitiHu5xSTzjG6cj0olm46rsJ0h3hij58MOw==
=3bbH
-----END PGP PUBLIC KEY BLOCK-----

View File

@ -0,0 +1,28 @@
//go:build !windows
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package docker
import (
"os"
"golang.org/x/sys/unix"
)
func isInteractive() bool {
_, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
return err == nil
}

View File

@ -0,0 +1,36 @@
//go:build windows
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package docker
import (
"os"
"golang.org/x/sys/windows"
)
func isInteractive() bool {
handle := windows.Handle(os.Stdout.Fd())
var mode uint32
if err := windows.GetConsoleMode(handle, &mode); err != nil {
return false
}
mode |= windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
if err := windows.SetConsoleMode(handle, mode); err != nil {
return false
}
return true
}

View File

@ -50,11 +50,14 @@ func CmdOut(ctx context.Context, args ...string) (string, string, error) {
return exec.RunOut(ctx, "docker", args...)
}
func Build(ctx context.Context, tag, dockerfile, dir string, buildArgs ...string) error {
func Build(ctx context.Context, pull bool, tag, dockerfile, dir, platform string, buildArgs ...string) error {
if dockerfile == "" {
dockerfile = filepath.Join(dir, "Dockerfile")
}
args := []string{"image", "build", "-t", tag, "-f", dockerfile}
args := []string{"image", "build", "-t", tag, "-f", dockerfile, "--platform", platform}
if pull {
args = append(args, "--pull")
}
for _, v := range buildArgs {
args = append(args, "--build-arg", v)
}
@ -92,12 +95,19 @@ func ImageList(ctx context.Context, tag string) ([]string, error) {
return imgs, s.Err()
}
func Pull(ctx context.Context, tag string) error {
return Cmd(ctx, "image", "pull", tag)
func ImageSave(ctx context.Context, tag, file string) error {
return Cmd(ctx, "image", "save", "-o", file, tag)
}
func Pull(ctx context.Context, platform, tag string) error {
return Cmd(ctx, "image", "pull", "--platform", platform, tag)
}
func Push(ctx context.Context, tag string) error {
return Cmd(ctx, "image", "push", tag)
}
func RunInteractiveAndRemove(ctx context.Context, args ...string) error {
logrus.Tracef("running 'docker run --rm -i -t %s'", strings.Join(args, " "))
cmd := exec.CommandContext(ctx, "docker", append([]string{"run", "--rm", "-it"}, args...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
@ -127,8 +137,18 @@ func RunD2VM(ctx context.Context, image, version, in, out, cmd string, args ...s
if version == "" {
version = "latest"
}
a := []string{
a := []string{"run", "--rm"}
interactive := isInteractive()
if interactive {
a = append(a, "-i", "-t")
}
a = append(a,
"--privileged",
"-e",
// yes... it is kind of a dirty hack
fmt.Sprintf("SUDO_UID=%d", os.Getuid()),
"-v",
fmt.Sprintf("%s:/var/run/docker.sock", dockerSocket()),
"-v",
@ -139,6 +159,12 @@ func RunD2VM(ctx context.Context, image, version, in, out, cmd string, args ...s
"/d2vm",
fmt.Sprintf("%s:%s", image, version),
cmd,
)
c := exec.CommandContext(ctx, "docker", append(a, args...)...)
if interactive {
c.Stdin = os.Stdin
}
return RunInteractiveAndRemove(ctx, append(a, args...)...)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return c.Run()
}

View File

@ -26,8 +26,6 @@ import (
var (
Run = RunNoOut
CommandContext = exec.CommandContext
)
func SetDebug(debug bool) {
@ -39,6 +37,11 @@ func SetDebug(debug bool) {
}
}
func CommandContext(ctx context.Context, c string, args ...string) *exec.Cmd {
logrus.Debugf("$ %s %s", c, strings.Join(args, " "))
return exec.CommandContext(ctx, c, args...)
}
func RunDebug(ctx context.Context, c string, args ...string) error {
logrus.Debugf("$ %s %s", c, strings.Join(args, " "))
cmd := exec.CommandContext(ctx, c, args...)

148
pkg/qemu/config.go Normal file
View File

@ -0,0 +1,148 @@
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package qemu
import (
"io"
"github.com/google/uuid"
)
type Option func(c *config)
type Disk struct {
Path string
Size int
Format string
}
type PublishedPort struct {
Guest uint16
Host uint16
Protocol string
}
// config contains the config for Qemu
type config struct {
path string
uuid uuid.UUID
gui bool
disks []Disk
networking string
arch string
cpus uint
memory uint
bios string
accel string
detached bool
qemuBinPath string
qemuImgPath string
publishedPorts []PublishedPort
netdevConfig string
stdin io.Reader
stdout io.Writer
stderr io.Writer
}
func WithGUI() Option {
return func(c *config) {
c.gui = true
}
}
func WithDisks(disks ...Disk) Option {
return func(c *config) {
c.disks = disks
}
}
func WithNetworking(networking string) Option {
return func(c *config) {
c.networking = networking
}
}
func WithArch(arch string) Option {
return func(c *config) {
c.arch = arch
}
}
func WithCPUs(cpus uint) Option {
return func(c *config) {
c.cpus = cpus
}
}
func WithMemory(memory uint) Option {
return func(c *config) {
c.memory = memory
}
}
func WithBios(bios string) Option {
return func(c *config) {
c.bios = bios
}
}
func WithAccel(accel string) Option {
return func(c *config) {
c.accel = accel
}
}
func WithDetached() Option {
return func(c *config) {
c.detached = true
}
}
func WithQemuBinPath(path string) Option {
return func(c *config) {
c.qemuBinPath = path
}
}
func WithQemuImgPath(path string) Option {
return func(c *config) {
c.qemuImgPath = path
}
}
func WithPublishedPorts(ports ...PublishedPort) Option {
return func(c *config) {
c.publishedPorts = ports
}
}
func WithStdin(r io.Reader) Option {
return func(c *config) {
c.stdin = r
}
}
func WithStdout(w io.Writer) Option {
return func(c *config) {
c.stdout = w
}
}
func WithStderr(w io.Writer) Option {
return func(c *config) {
c.stderr = w
}
}

367
pkg/qemu/qemu.go Normal file
View File

@ -0,0 +1,367 @@
package qemu
import (
"context"
"crypto/rand"
"fmt"
"net"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
)
const (
NetworkingNone = "none"
NetworkingUser = "user"
NetworkingTap = "tap"
NetworkingBridge = "bridge"
NetworkingDefault = NetworkingUser
)
var (
defaultArch string
defaultAccel string
)
func init() {
switch runtime.GOARCH {
case "arm64":
defaultArch = "aarch64"
case "amd64":
defaultArch = "x86_64"
case "s390x":
defaultArch = "s390x"
}
switch {
case runtime.GOARCH == "s390x":
defaultAccel = "kvm"
case haveKVM():
defaultAccel = "kvm:tcg"
case runtime.GOOS == "darwin":
defaultAccel = "hvf:tcg"
}
}
func Run(ctx context.Context, path string, opts ...Option) error {
config := &config{}
for _, o := range opts {
o(config)
}
config.path = path
// Generate UUID, so that /sys/class/dmi/id/product_uuid is populated
config.uuid = uuid.New()
// These envvars override the corresponding command line
// options. So this must remain after the `flags.Parse` above.
// accel = GetStringValue("LINUXKIT_QEMU_ACCEL", accel, "")
if config.arch == "" {
config.arch = defaultArch
}
if config.accel == "" {
config.accel = defaultAccel
}
if _, err := os.Stat(config.path); err != nil {
return err
}
if config.cpus == 0 {
config.cpus = 1
}
if config.memory == 0 {
config.memory = 1024
}
for i, d := range config.disks {
id := ""
if i != 0 {
id = strconv.Itoa(i)
}
if d.Size != 0 && d.Format == "" {
d.Format = "qcow2"
}
if d.Size != 0 && d.Path == "" {
d.Path = "disk" + id + ".img"
}
if d.Path == "" {
return fmt.Errorf("disk specified with no size or name")
}
config.disks[i] = d
}
config.disks = append([]Disk{{Path: config.path}}, config.disks...)
if config.networking == "" || config.networking == "default" {
dflt := NetworkingDefault
config.networking = dflt
}
netMode := strings.SplitN(config.networking, ",", 2)
switch netMode[0] {
case NetworkingUser:
config.netdevConfig = "user,id=t0"
case NetworkingTap:
if len(netMode) != 2 {
return fmt.Errorf("Not enough arguments for %q networking mode", NetworkingTap)
}
if len(config.publishedPorts) != 0 {
return fmt.Errorf("Port publishing requires %q networking mode", NetworkingUser)
}
config.netdevConfig = fmt.Sprintf("tap,id=t0,ifname=%s,script=no,downscript=no", netMode[1])
case NetworkingBridge:
if len(netMode) != 2 {
return fmt.Errorf("Not enough arguments for %q networking mode", NetworkingBridge)
}
if len(config.publishedPorts) != 0 {
return fmt.Errorf("Port publishing requires %q networking mode", NetworkingUser)
}
config.netdevConfig = fmt.Sprintf("bridge,id=t0,br=%s", netMode[1])
case NetworkingNone:
if len(config.publishedPorts) != 0 {
return fmt.Errorf("Port publishing requires %q networking mode", NetworkingUser)
}
config.netdevConfig = ""
default:
return fmt.Errorf("Invalid networking mode: %s", netMode[0])
}
if err := config.discoverBinaries(); err != nil {
log.Fatal(err)
}
return config.runQemuLocal(ctx)
}
func (c *config) runQemuLocal(ctx context.Context) (err error) {
var args []string
args, err = c.buildQemuCmdline()
if err != nil {
return err
}
for _, d := range c.disks {
// If disk doesn't exist then create one
if _, err := os.Stat(d.Path); err != nil {
if os.IsNotExist(err) {
log.Debugf("Creating new qemu disk [%s] format %s", d.Path, d.Format)
qemuImgCmd := exec.Command(c.qemuImgPath, "create", "-f", d.Format, d.Path, fmt.Sprintf("%dM", d.Size))
log.Debugf("%v", qemuImgCmd.Args)
if err := qemuImgCmd.Run(); err != nil {
return fmt.Errorf("Error creating disk [%s] format %s: %s", d.Path, d.Format, err.Error())
}
} else {
return err
}
} else {
log.Infof("Using existing disk [%s] format %s", d.Path, d.Format)
}
}
// Detached mode is only supported in a container.
if c.detached == true {
return fmt.Errorf("Detached mode is only supported when running in a container, not locally")
}
qemuCmd := exec.CommandContext(ctx, c.qemuBinPath, args...)
// If verbosity is enabled print out the full path/arguments
log.Debugf("%v", qemuCmd.Args)
// If we're not using a separate window then link the execution to stdin/out
if c.gui == true {
qemuCmd.Stdin = nil
qemuCmd.Stdout = nil
qemuCmd.Stderr = nil
} else {
qemuCmd.Stdin = c.stdin
qemuCmd.Stdout = c.stdout
qemuCmd.Stderr = c.stderr
}
return qemuCmd.Run()
}
func (c *config) buildQemuCmdline() ([]string, error) {
// Iterate through the flags and build arguments
var qemuArgs []string
qemuArgs = append(qemuArgs, "-smp", fmt.Sprintf("%d", c.cpus))
qemuArgs = append(qemuArgs, "-m", fmt.Sprintf("%d", c.memory))
qemuArgs = append(qemuArgs, "-uuid", c.uuid.String())
if c.bios != "" {
qemuArgs = append(qemuArgs, "-bios", c.bios)
}
// Need to specify the vcpu type when running qemu on arm64 platform, for security reason,
// the vcpu should be "host" instead of other names such as "cortex-a53"...
if c.arch == "aarch64" {
if runtime.GOARCH == "arm64" {
qemuArgs = append(qemuArgs, "-cpu", "host")
} else {
qemuArgs = append(qemuArgs, "-cpu", "cortex-a57")
}
}
// goArch is the GOARCH equivalent of config.Arch
var goArch string
switch c.arch {
case "s390x":
goArch = "s390x"
case "aarch64":
goArch = "arm64"
case "x86_64":
goArch = "amd64"
default:
return nil, fmt.Errorf("%s is an unsupported architecture.", c.arch)
}
if goArch != runtime.GOARCH {
log.Infof("Disable acceleration as %s != %s", c.arch, runtime.GOARCH)
c.accel = ""
}
if c.accel != "" {
switch c.arch {
case "s390x":
qemuArgs = append(qemuArgs, "-machine", fmt.Sprintf("s390-ccw-virtio,accel=%s", c.accel))
case "aarch64":
gic := ""
// VCPU supports less PA bits (36) than requested by the memory map (40)
highmem := "highmem=off,"
if runtime.GOOS == "linux" {
// gic-version=host requires KVM, which implies Linux
gic = "gic_version=host,"
highmem = ""
}
qemuArgs = append(qemuArgs, "-machine", fmt.Sprintf("virt,%s%saccel=%s", gic, highmem, c.accel))
default:
qemuArgs = append(qemuArgs, "-machine", fmt.Sprintf("q35,accel=%s", c.accel))
}
} else {
switch c.arch {
case "s390x":
qemuArgs = append(qemuArgs, "-machine", "s390-ccw-virtio")
case "aarch64":
qemuArgs = append(qemuArgs, "-machine", "virt")
default:
qemuArgs = append(qemuArgs, "-machine", "q35")
}
}
// rng-random does not work on macOS
// Temporarily disable it until fixed upstream.
if runtime.GOOS != "darwin" {
rng := "rng-random,id=rng0"
if runtime.GOOS == "linux" {
rng = rng + ",filename=/dev/urandom"
}
if c.arch == "s390x" {
qemuArgs = append(qemuArgs, "-object", rng, "-device", "virtio-rng-ccw,rng=rng0")
} else {
qemuArgs = append(qemuArgs, "-object", rng, "-device", "virtio-rng-pci,rng=rng0")
}
}
var lastDisk int
for i, d := range c.disks {
index := i
if d.Format != "" {
qemuArgs = append(qemuArgs, "-drive", "file="+d.Path+",format="+d.Format+",index="+strconv.Itoa(index)+",media=disk")
} else {
qemuArgs = append(qemuArgs, "-drive", "file="+d.Path+",index="+strconv.Itoa(index)+",media=disk")
}
lastDisk = index
}
// Ensure CDROMs start from at least hdc
if lastDisk < 2 {
lastDisk = 2
}
if c.netdevConfig != "" {
mac := generateMAC()
if c.arch == "s390x" {
qemuArgs = append(qemuArgs, "-device", "virtio-net-ccw,netdev=t0,mac="+mac.String())
} else {
qemuArgs = append(qemuArgs, "-device", "virtio-net-pci,netdev=t0,mac="+mac.String())
}
forwardings, err := buildQemuForwardings(c.publishedPorts)
if err != nil {
log.Error(err)
}
qemuArgs = append(qemuArgs, "-netdev", c.netdevConfig+forwardings)
} else {
qemuArgs = append(qemuArgs, "-net", "none")
}
if c.gui != true {
qemuArgs = append(qemuArgs, "-nographic")
}
return qemuArgs, nil
}
func (c *config) discoverBinaries() error {
if c.qemuImgPath != "" {
return nil
}
qemuBinPath := "qemu-system-" + c.arch
qemuImgPath := "qemu-img"
var err error
c.qemuBinPath, err = exec.LookPath(qemuBinPath)
if err != nil {
return fmt.Errorf("Unable to find %s within the $PATH", qemuBinPath)
}
c.qemuImgPath, err = exec.LookPath(qemuImgPath)
if err != nil {
return fmt.Errorf("Unable to find %s within the $PATH", qemuImgPath)
}
return nil
}
func buildQemuForwardings(publishedPorts []PublishedPort) (string, error) {
if len(publishedPorts) == 0 {
return "", nil
}
var forwardings string
for _, p := range publishedPorts {
hostPort := p.Host
guestPort := p.Guest
forwardings = fmt.Sprintf("%s,hostfwd=%s::%d-:%d", forwardings, p.Protocol, hostPort, guestPort)
}
return forwardings, nil
}
func haveKVM() bool {
_, err := os.Stat("/dev/kvm")
return !os.IsNotExist(err)
}
func generateMAC() net.HardwareAddr {
mac := make([]byte, 6)
n, err := rand.Read(mac)
if err != nil {
log.WithError(err).Fatal("failed to generate random mac address")
}
if n != 6 {
log.WithError(err).Fatalf("generated %d bytes for random mac address", n)
}
mac[0] &^= 0x01 // Clear multicast bit
mac[0] |= 0x2 // Set locally administered bit
return mac
}

114
pkg/qemu_img/qemu_img.go Normal file
View File

@ -0,0 +1,114 @@
// Copyright 2022 Linka Cloud All rights reserved.
//
// Licensed 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.
package qemu_img
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"go.linka.cloud/d2vm/pkg/docker"
exec2 "go.linka.cloud/d2vm/pkg/exec"
)
var (
DockerImageName string
DockerImageVersion string
)
type ImgInfo struct {
VirtualSize int `json:"virtual-size"`
Filename string `json:"filename"`
Format string `json:"format"`
ActualSize int `json:"actual-size"`
DirtyFlag bool `json:"dirty-flag"`
}
func Info(ctx context.Context, in string) (*ImgInfo, error) {
var (
o []byte
err error
)
if path, _ := exec.LookPath("qemu-img"); path == "" {
inAbs, err := filepath.Abs(in)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path for %q: %v", path, err)
}
inMount := filepath.Dir(inAbs)
in := filepath.Join("/in", filepath.Base(inAbs))
o, err = exec2.CommandContext(
ctx,
"docker",
"run",
"--rm",
"-v",
inMount+":/in",
"--entrypoint",
"qemu-img",
fmt.Sprintf("%s:%s", DockerImageName, DockerImageVersion),
"info",
in,
"--output",
"json",
).CombinedOutput()
} else {
o, err = exec2.CommandContext(ctx, "qemu-img", "info", in, "--output", "json").CombinedOutput()
}
if err != nil {
return nil, fmt.Errorf("%v: %s", err, string(o))
}
var i ImgInfo
if err := json.Unmarshal(o, &i); err != nil {
return nil, err
}
return &i, nil
}
func Convert(ctx context.Context, format, in, out string) error {
if path, _ := exec.LookPath("qemu-img"); path != "" {
return exec2.Run(ctx, "qemu-img", "convert", "-O", format, in, out)
}
inAbs, err := filepath.Abs(in)
if err != nil {
return fmt.Errorf("failed to get absolute path for %q: %v", in, err)
}
inMount := filepath.Dir(inAbs)
in = filepath.Join("/in", filepath.Base(inAbs))
outAbs, err := filepath.Abs(out)
if err != nil {
return fmt.Errorf("failed to get absolute path for %q: %v", out, err)
}
outMount := filepath.Dir(outAbs)
out = filepath.Join("/out", filepath.Base(outAbs))
return docker.RunAndRemove(
ctx,
"-v",
fmt.Sprintf("%s:/in", inMount),
"-v",
fmt.Sprintf("%s:/out", outMount),
"--entrypoint",
"qemu-img",
fmt.Sprintf("%s:%s", DockerImageName, DockerImageVersion),
"convert",
"-O",
format,
in,
out,
)
}

100
syslinux.go Normal file
View File

@ -0,0 +1,100 @@
// Copyright 2023 Linka Cloud All rights reserved.
//
// Licensed 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.
package d2vm
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/sirupsen/logrus"
"go.linka.cloud/d2vm/pkg/exec"
)
const syslinuxCfg = `DEFAULT linux
SAY Now booting the kernel from SYSLINUX...
LABEL linux
KERNEL %s
APPEND %s
`
var mbrPaths = []string{
// debian path
"/usr/lib/syslinux/mbr/mbr.bin",
// ubuntu path
"/usr/lib/EXTLINUX/mbr.bin",
// alpine path
"/usr/share/syslinux/mbr.bin",
// centos path
"/usr/share/syslinux/mbr.bin",
// archlinux path
"/usr/lib/syslinux/bios/mbr.bin",
}
type syslinux struct {
c Config
mbrBin string
}
func (s syslinux) Validate(_ BootFS) error {
return nil
}
func (s syslinux) Setup(ctx context.Context, dev, root string, cmdline string) error {
logrus.Infof("setting up syslinux bootloader")
if err := exec.Run(ctx, "extlinux", "--install", filepath.Join(root, "boot")); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(root, "boot", "syslinux.cfg"), []byte(fmt.Sprintf(syslinuxCfg, s.c.Kernel, cmdline)), perm); err != nil {
return err
}
logrus.Infof("writing MBR")
if err := exec.Run(ctx, "dd", fmt.Sprintf("if=%s", s.mbrBin), fmt.Sprintf("of=%s", dev), "bs=440", "count=1", "conv=notrunc"); err != nil {
return err
}
return nil
}
type syslinuxProvider struct{}
func (s syslinuxProvider) New(c Config, _ OSRelease, arch string) (Bootloader, error) {
if arch != "x86_64" {
return nil, fmt.Errorf("syslinux is only supported for amd64")
}
mbrBin := ""
for _, v := range mbrPaths {
if _, err := os.Stat(v); err == nil {
mbrBin = v
break
}
}
if mbrBin == "" {
return nil, fmt.Errorf("unable to find syslinux's mbr.bin path")
}
return &syslinux{
c: c,
mbrBin: mbrBin,
}, nil
}
func (s syslinuxProvider) Name() string {
return "syslinux"
}
func init() {
RegisterBootloaderProvider(syslinuxProvider{})
}

View File

@ -2,17 +2,24 @@ FROM {{ .Image }}
USER root
RUN apk update --no-cache && \
apk add \
RUN apk add --no-cache \
util-linux \
linux-virt \
{{- if ge .Release.VersionID "3.17" }}
busybox-openrc \
busybox-mdev-openrc \
busybox-extras-openrc \
busybox-mdev-openrc \
{{- else }}
busybox-initscripts \
openrc
{{- end }}
openrc && \
find /boot -type l -exec rm {} \;
RUN for s in bootmisc hostname hwclock modules networking swap sysctl urandom syslog; do rc-update add $s boot; done
RUN for s in devfs dmesg hwdrivers mdev; do rc-update add $s sysinit; done
RUN echo "root:{{- if .Password}}{{ .Password}}{{- else}}root{{- end}}" | chpasswd
{{ if .Password }}RUN echo "root:{{ .Password }}" | chpasswd {{ end }}
{{ if eq .NetworkManager "ifupdown"}}
RUN apk add --no-cache ifupdown-ng
@ -22,3 +29,22 @@ allow-hotplug eth0\n\
iface eth0 inet dhcp\n\
' > /etc/network/interfaces
{{ end }}
{{ if .Luks }}
RUN apk add --no-cache cryptsetup && \
source /etc/mkinitfs/mkinitfs.conf && \
echo "features=\"${features} cryptsetup\"" > /etc/mkinitfs/mkinitfs.conf && \
mkinitfs $(ls /lib/modules)
{{- end }}
# we need to keep that at the end, because after it, we can't install packages without error anymore due to grub hooks
{{- if .Grub }}
RUN apk add --no-cache \
{{- if .GrubBIOS }}
grub-bios \
{{- end }}
{{- if .GrubEFI }}
grub-efi \
{{- end }}
grub
{{- end }}

View File

@ -7,14 +7,38 @@ RUN sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-* && \
RUN yum update -y
RUN yum install -y kernel systemd NetworkManager e2fsprogs sudo && \
# See https://bugzilla.redhat.com/show_bug.cgi?id=1917213
RUN yum install -y \
kernel \
systemd \
NetworkManager \
{{- if .GrubBIOS }}
grub2 \
{{- end }}
{{- if .GrubEFI }}
grub2 grub2-efi-x64 grub2-efi-x64-modules \
{{- end }}
e2fsprogs \
sudo && \
systemctl enable NetworkManager && \
systemctl unmask systemd-remount-fs.service && \
systemctl unmask getty.target
systemctl unmask getty.target && \
find /boot -type l -exec rm {} \;
RUN dracut --no-hostonly --regenerate-all --force && \
cd /boot && \
ln -s $(find . -name 'vmlinuz-*') vmlinuz && \
ln -s $(find . -name 'initramfs-*.img') initrd.img
{{ if .Luks }}
RUN yum install -y cryptsetup && \
dracut --no-hostonly --regenerate-all --force --install="/usr/sbin/cryptsetup"
{{ else }}
RUN dracut --no-hostonly --regenerate-all --force
{{ end }}
RUN echo "root:{{- if .Password}}{{ .Password}}{{- else}}root{{- end}}" | chpasswd
{{ if .Password }}RUN echo "root:{{ .Password }}" | chpasswd {{ end }}
{{- if not .Grub }}
RUN cd /boot && \
mv $(find . -name 'vmlinuz-*') /boot/vmlinuz && \
mv $(find . -name 'initramfs-*.img') /boot/initrd.img
{{- end }}
RUN yum clean all && \
rm -rf /var/cache/yum

View File

@ -2,13 +2,33 @@ FROM {{ .Image }}
USER root
RUN apt-get -y update && \
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
linux-image-amd64
{{- if eq .Release.VersionID "9" }}
RUN echo "deb http://archive.debian.org/debian stretch main" > /etc/apt/sources.list && \
echo "deb-src http://archive.debian.org/debian stretch main" >> /etc/apt/sources.list && \
echo "deb http://archive.debian.org/debian stretch-backports main" >> /etc/apt/sources.list && \
echo "deb http://archive.debian.org/debian-security stretch/updates main" >> /etc/apt/sources.list && \
echo "deb-src http://archive.debian.org/debian-security stretch/updates main" >> /etc/apt/sources.list
{{- end }}
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
linux-image-amd64 && \
find /boot -type l -exec rm {} \;
RUN ARCH="$([ "$(uname -m)" = "x86_64" ] && echo amd64 || echo arm64)"; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
systemd-sysv \
systemd \
{{- if .Grub }}
grub-common \
grub2-common \
{{- end }}
{{- if .GrubBIOS }}
grub-pc-bin \
{{- end }}
{{- if .GrubEFI }}
grub-efi-${ARCH}-bin \
{{- end }}
dbus \
iproute2 \
isc-dhcp-client \
@ -16,7 +36,7 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
RUN systemctl preset-all
RUN echo "root:{{- if .Password}}{{ .Password}}{{- else}}root{{- end}}" | chpasswd
{{ if .Password }}RUN echo "root:{{ .Password }}" | chpasswd {{ end }}
{{ if eq .NetworkManager "netplan" }}
RUN apt install -y netplan.io
@ -41,3 +61,19 @@ allow-hotplug eth0\n\
iface eth0 inet dhcp\n\
' > /etc/network/interfaces
{{ end }}
{{- if .Luks }}
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cryptsetup-initramfs && \
echo "CRYPTSETUP=y" >> /etc/cryptsetup-initramfs/conf-hook && \
update-initramfs -u -v
{{- end }}
# needs to be after update-initramfs
{{- if not .Grub }}
RUN mv $(find /boot -name 'vmlinuz-*') /boot/vmlinuz && \
mv $(find /boot -name 'initrd.img-*') /boot/initrd.img
{{- end }}
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*

View File

@ -2,20 +2,32 @@ FROM {{ .Image }}
USER root
RUN apt-get update -y && \
RUN ARCH="$([ "$(uname -m)" = "x86_64" ] && echo amd64 || echo arm64)"; \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
linux-image-virtual \
initramfs-tools \
systemd-sysv \
systemd \
{{- if .Grub }}
grub-common \
grub2-common \
{{- end }}
{{- if .GrubBIOS }}
grub-pc-bin \
{{- end }}
{{- if .GrubEFI }}
grub-efi-${ARCH}-bin \
{{- end }}
dbus \
isc-dhcp-client \
iproute2 \
iputils-ping
iputils-ping && \
find /boot -type l -exec rm {} \;
RUN systemctl preset-all
RUN echo "root:{{- if .Password}}{{ .Password}}{{- else}}root{{- end}}" | chpasswd
{{ if .Password }}RUN echo "root:{{ .Password }}" | chpasswd {{ end }}
{{ if eq .NetworkManager "netplan" }}
RUN apt install -y netplan.io
@ -40,3 +52,17 @@ allow-hotplug eth0\n\
iface eth0 inet dhcp\n\
' > /etc/network/interfaces
{{ end }}
{{- if .Luks }}
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cryptsetup-initramfs && \
update-initramfs -u -v
{{- end }}
# needs to be after update-initramfs
{{- if not .Grub }}
RUN mv $(find /boot -name 'vmlinuz-*') /boot/vmlinuz && \
mv $(find /boot -name 'initrd.img-*') /boot/initrd.img
{{- end }}
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*

View File

@ -14,8 +14,21 @@
package d2vm
import (
"fmt"
"runtime"
"go.linka.cloud/d2vm/pkg/qemu_img"
)
var (
Arch = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
Version = ""
BuildDate = ""
Image = ""
Image = "linkacloud/d2vm"
)
func init() {
qemu_img.DockerImageName = Image
qemu_img.DockerImageVersion = Version
}