2
0
mirror of https://github.com/linka-cloud/d2vm.git synced 2026-01-25 19:15:04 +00:00

34 Commits

Author SHA1 Message Date
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
2970af4873 update command line docs
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-06-04 14:47:40 +02:00
9abb66ad1d run/hetzner: add split-boot support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-04-23 17:30:45 +02:00
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
93ba19a1fa fix execution exit code on error
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
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
8c36d42e06 luks: fix centos Dockerfile template
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
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
f7b4861b1d tests: fix permissions error
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
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
f75b0c7313 luks: fix ubuntu <22.04 support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
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
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
4780228c95 luks: implements support for centos
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-03-01 14:45:27 +01:00
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
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
fae73e71bc chore: upgrade go to 1.20
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-23 13:30:24 +01:00
74aac3fdcb actions: add end-to-end tests
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-23 13:30:24 +01:00
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
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
532ee3f1a3 add split boot partiton support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-22 18:09:18 +01:00
490f235c6d docs: configure CNAME
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-17 00:39:14 +01:00
466d6d40d3 deps: go mod tidy
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-15 11:29:40 +01:00
bf2687a211 docs: add container disk support
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2023-02-15 11:06:52 +01:00
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
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
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
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
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
43f2dd5452 Makefile: install: fix missing go generate
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 12:34:34 +02:00
72413b0bac docs: add homebrew install instructions
Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
2022-09-12 12:05:27 +02:00
54 changed files with 1853 additions and 1856 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
@@ -45,6 +45,44 @@ jobs:
- name: Run tests
run: git --no-pager diff --exit-code HEAD~1 HEAD **/**.go templates/ || make tests
e2e-tests:
name: End to end Tests
runs-on: ubuntu-latest
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 }}-e2e-tests-${{ github.sha }}
restore-keys: |
${{ runner.os }}-tests-
- name: Run end-to-end tests
run: make e2e
docs-up-to-date:
name: Docs up to date
runs-on: ubuntu-latest
@@ -59,7 +97,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 +126,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 +157,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 +166,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 +175,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 +186,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 +220,13 @@ jobs:
release:
name: Release Binaries
runs-on: ubuntu-18.04
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/v')
needs:
- tests
- docs-up-to-date
- build
- e2e-tests
steps:
- name: Checkout
@@ -197,7 +238,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 +269,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 +287,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

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ scratch
*.qcow2
*.vmdk
*.vdi
.DS_Store
bin/
dist/

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,15 @@ RUN apt-get update && \
util-linux \
udev \
parted \
kpartx \
e2fsprogs \
mount \
tar \
extlinux \
qemu-utils
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

@@ -64,11 +64,14 @@ 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 20m -v
e2e: docker-build .build
@go test -v -exec sudo -count=1 -timeout 60m -ldflags "-X '$(MODULE).Version=$(VERSION)' -X '$(MODULE).BuildDate=$(shell date)'" ./e2e
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)" = "" ]
@@ -79,6 +82,7 @@ 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:

100
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,23 +42,70 @@ 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
- mount
- tar
- extlinux
- 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
#### With Docker
*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
wich 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:
Extract the tarball, then move the extracted *d2vm* binary to somewhere in your `$PATH` (`/usr/local/bin` for most users).
```bash
tar -xvzf <RELEASE-TARBALL-NAME>.tar.gz
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/
```
Move the extracted *d2vm* binary to somewhere in your `$PATH` (`/usr/local/bin` for most users).
### Install from source
#### From source
Clone the git repository:
@@ -105,20 +157,25 @@ Usage:
Flags:
--append-to-cmdline string Extra kernel cmdline arguments to append to the generated one
-f, --force Override output qcow2 image
--boot-size uint Size of the boot partition in MB (default 100)
--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
--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:
@@ -255,18 +312,24 @@ Usage:
Flags:
--append-to-cmdline string Extra kernel cmdline arguments to append to the generated one
--boot-size uint Size of the boot partition in MB (default 100)
--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
--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
```
@@ -281,6 +344,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.

View File

@@ -23,6 +23,7 @@ import (
"strings"
"github.com/c2h5oh/datasize"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"go.uber.org/multierr"
@@ -96,6 +97,8 @@ func sysconfig(osRelease OSRelease) (string, error) {
return syslinuxCfgUbuntu, nil
case ReleaseDebian:
return syslinuxCfgDebian, nil
case ReleaseKali:
return syslinuxCfgDebian, nil
case ReleaseAlpine:
return syslinuxCfgAlpine, nil
case ReleaseCentOS:
@@ -119,21 +122,41 @@ type builder struct {
diskOut string
format string
size int64
size uint64
mntPoint string
splitBoot bool
bootSize uint64
mbrPath string
loDevice string
loPart string
diskUUD string
loDevice string
bootPart string
rootPart string
cryptPart string
cryptRoot string
mappedCryptRoot string
bootUUID string
rootUUID string
cryptUUID string
luksPassword string
cmdLineExtra string
}
func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size int64, osRelease OSRelease, format string, cmdLineExtra string) (Builder, error) {
func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size uint64, osRelease OSRelease, format string, cmdLineExtra string, splitBoot bool, bootSize uint64, luksPassword string) (Builder, error) {
if err := checkDependencies(); err != nil {
return nil, err
}
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
for _, v := range formats {
@@ -145,6 +168,14 @@ func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size int64, o
return nil, fmt.Errorf("invalid format: %s valid formats are: %s", f, strings.Join(formats, " "))
}
if splitBoot && bootSize < 50 {
return nil, fmt.Errorf("boot partition size must be at least 50MiB")
}
if splitBoot && bootSize >= size {
return nil, fmt.Errorf("boot partition size must be less than the disk size")
}
mbrBin := ""
for _, v := range mbrPaths {
if _, err := os.Stat(v); err == nil {
@@ -156,7 +187,7 @@ func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size int64, o
return nil, fmt.Errorf("unable to find syslinux's mbr.bin path")
}
if size == 0 {
size = 10 * int64(datasize.GB)
size = 10 * uint64(datasize.GB)
}
if disk == "" {
disk = "disk0"
@@ -184,6 +215,9 @@ func NewBuilder(ctx context.Context, workdir, imgTag, disk string, size int64, o
mbrPath: mbrBin,
mntPoint: filepath.Join(workdir, "/mnt"),
cmdLineExtra: cmdLineExtra,
splitBoot: splitBoot,
bootSize: bootSize,
luksPassword: luksPassword,
}
if err := os.MkdirAll(b.mntPoint, os.ModePerm); err != nil {
return nil, err
@@ -250,9 +284,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
}
@@ -263,15 +309,60 @@ 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 err := exec.Run(ctx, "mkfs.ext4", b.bootPart); err != nil {
return err
}
if err := exec.Run(ctx, "mount", b.bootPart, filepath.Join(b.mntPoint, "boot")); err != nil {
return err
}
return nil
@@ -280,13 +371,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 {
@@ -297,14 +393,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 ext4 errors=remount-ro 0 2\n", b.rootUUID, b.bootUUID)
} 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
}
@@ -324,21 +443,51 @@ func (b *builder) setupRootFS(ctx context.Context) error {
if err := os.RemoveAll(b.chPath("/.dockerenv")); err != nil {
return err
}
if b.osRelease.ID != ReleaseAlpine {
// create a symlink to /boot for non-alpine images in order to have a consistent path
// even if the image is not split
if _, err := os.Stat(b.chPath("/boot/boot")); os.IsNotExist(err) {
if err := os.Symlink(".", b.chPath("/boot/boot")); err != nil {
return err
}
}
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
case ReleaseUbuntu:
if b.osRelease.VersionID >= "20.04" {
return nil
}
fallthrough
case ReleaseDebian, ReleaseKali:
t, err := os.Readlink(b.chPath("/vmlinuz"))
if err != nil {
return err
}
if err := os.Symlink(t, b.chPath("/boot/vmlinuz")); err != nil {
return err
}
t, err = os.Readlink(b.chPath("/initrd.img"))
if err != nil {
return err
}
if err := os.Symlink(t, b.chPath("/boot/initrd.img")); 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 {
@@ -350,7 +499,25 @@ func (b *builder) installKernel(ctx context.Context) error {
if err != nil {
return err
}
if err := b.chWriteFile("/boot/syslinux.cfg", fmt.Sprintf(sysconfig, b.diskUUD, b.cmdLineExtra), perm); err != nil {
var cfg string
if b.isLuksEnabled() {
switch b.osRelease.ID {
case ReleaseAlpine:
cfg = fmt.Sprintf(sysconfig, b.rootUUID, fmt.Sprintf("%s root=/dev/mapper/root cryptdm=root", b.cmdLineExtra))
cfg = strings.Replace(cfg, "root=UUID="+b.rootUUID, "cryptroot=UUID="+b.cryptUUID, 1)
case ReleaseCentOS:
cfg = fmt.Sprintf(sysconfig, b.rootUUID, fmt.Sprintf("%s rd.luks.name=UUID=%s rd.luks.uuid=%s rd.luks.crypttab=0", b.cmdLineExtra, b.rootUUID, b.cryptUUID))
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
cfg = fmt.Sprintf(sysconfig, b.rootUUID, fmt.Sprintf("%s root=/dev/mapper/root cryptopts=target=root,source=UUID=%s,key=none,luks", b.cmdLineExtra, b.cryptUUID))
cfg = strings.Replace(cfg, "root=UUID="+b.rootUUID, "", 1)
}
} else {
cfg = fmt.Sprintf(sysconfig, b.rootUUID, b.cmdLineExtra)
}
if err := b.chWriteFile("/boot/syslinux.cfg", cfg, perm); err != nil {
return err
}
return nil
@@ -384,22 +551,26 @@ 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 {
var merr error
for _, v := range []string{"mount", "blkid", "tar", "losetup", "parted", "partprobe", "qemu-img", "extlinux", "dd", "mkfs"} {
for _, v := range []string{"mount", "blkid", "tar", "losetup", "parted", "kpartx", "qemu-img", "extlinux", "dd", "mkfs.ext4", "cryptsetup"} {
if _, err := exec2.LookPath(v); err != nil {
merr = multierr.Append(merr, err)
}
@@ -410,3 +581,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

@@ -42,7 +42,7 @@ func testSysconfig(t *testing.T, ctx context.Context, img, sysconf, kernel, init
sys, err := sysconfig(r)
require.NoError(t, err)
assert.Equal(t, sysconf, sys)
d, err := NewDockerfile(r, img, "root", "")
d, err := NewDockerfile(r, img, "root", "", false)
require.NoError(t, err)
logrus.Infof("docker image based on %s", d.Release.Name)
p := filepath.Join(tmpPath, docker.FormatImgName(img))
@@ -115,6 +115,18 @@ func TestSyslinuxCfg(t *testing.T) {
initrd: "/initrd.img",
sysconfig: syslinuxCfgDebian,
},
{
image: "kalilinux/kali-rolling:latest",
kernel: "/vmlinuz",
initrd: "/initrd.img",
sysconfig: syslinuxCfgDebian,
},
{
image: "alpine:3.16",
kernel: "/boot/vmlinuz-virt",
initrd: "/boot/initramfs-virt",
sysconfig: syslinuxCfgAlpine,
},
{
image: "alpine",
kernel: "/boot/vmlinuz-virt",

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,10 @@ var (
}
return docker.RunD2VM(cmd.Context(), d2vm.Image, d2vm.Version, in, out, cmd.Name(), os.Args[2:]...)
}
if luksPassword != "" && !splitBoot {
logrus.Warnf("luks password is set: enabling split boot")
splitBoot = true
}
size, err := parseSize(size)
if err != nil {
return err
@@ -87,6 +90,9 @@ var (
if file == "" {
file = filepath.Join(args[0], "Dockerfile")
}
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)
@@ -96,7 +102,7 @@ var (
if err := docker.Build(cmd.Context(), tag, file, args[0], buildArgs...); err != nil {
return err
}
return d2vm.Convert(
if err := d2vm.Convert(
cmd.Context(),
tag,
d2vm.WithSize(size),
@@ -105,7 +111,19 @@ var (
d2vm.WithCmdLineExtra(cmdLineExtra),
d2vm.WithNetworkManager(d2vm.NetworkManager(networkManager)),
d2vm.WithRaw(raw),
)
d2vm.WithSplitBoot(splitBoot),
d2vm.WithBootSize(bootSize),
d2vm.WithLuksPassword(luksPassword),
d2vm.WithKeepCache(keepCache),
); 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 +134,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", "", "Optional 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); 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

@@ -30,17 +30,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,15 +51,23 @@ var (
}
return docker.RunD2VM(cmd.Context(), d2vm.Image, d2vm.Version, out, out, cmd.Name(), dargs...)
}
if luksPassword != "" && !splitBoot {
logrus.Warnf("luks password is set: enabling split boot")
splitBoot = true
}
img := args[0]
tag := "latest"
if parts := strings.Split(img, ":"); len(parts) > 1 {
img, tag = parts[0], parts[1]
}
img = fmt.Sprintf("%s:%s", img, tag)
size, err := parseSize(size)
if err != nil {
return err
}
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)
@@ -75,9 +79,9 @@ var (
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 {
@@ -86,7 +90,7 @@ var (
return err
}
}
return d2vm.Convert(
if err := d2vm.Convert(
cmd.Context(),
img,
d2vm.WithSize(size),
@@ -95,27 +99,34 @@ var (
d2vm.WithCmdLineExtra(cmdLineExtra),
d2vm.WithNetworkManager(d2vm.NetworkManager(networkManager)),
d2vm.WithRaw(raw),
)
d2vm.WithSplitBoot(splitBoot),
d2vm.WithBootSize(bootSize),
d2vm.WithLuksPassword(luksPassword),
d2vm.WithKeepCache(keepCache),
); 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", "", "Optional 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)
}

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

@@ -0,0 +1,59 @@
// 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 (
"strings"
"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
splitBoot bool
bootSize uint64
luksPassword string
keepCache bool
)
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(&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")
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 = ""
force = false
verbose = false
timeFormat = ""
format = "qcow2"
@@ -77,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()}
}
@@ -133,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"
)
@@ -79,7 +81,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 +93,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
}
@@ -268,13 +270,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))
@@ -287,7 +307,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))
@@ -300,7 +320,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 (
@@ -37,8 +32,6 @@ var (
qemuDetached bool
networking string
publishFlags MultipleFlag
deviceFlags MultipleFlag
usbEnabled bool
QemuCmd = &cobra.Command{
Use: "qemu [options] [image-path]",
@@ -71,7 +64,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")
// VM configuration
flags.StringVar(&accel, "accel", defaultAccel, "Choose acceleration mode. Use 'tcg' to disable it.")
@@ -87,362 +79,45 @@ 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),
}
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)
}

View File

@@ -18,19 +18,18 @@ 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
@@ -191,15 +190,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 +199,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 +332,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,7 +86,7 @@ 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

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) 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, tag, dockerfile, tmpPath); err != nil {
return fmt.Errorf("failed to build container disk: %w", err)
}
return nil
}

View File

@@ -45,8 +45,13 @@ func Convert(ctx context.Context, img string, opts ...ConvertOption) error {
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 != "")
if err != nil {
return err
}
@@ -65,13 +70,17 @@ func Convert(ctx context.Context, img string, opts ...ConvertOption) error {
if err := docker.Build(ctx, imgUUID, p, dir); 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.bootSize, o.luksPassword)
if err != nil {
return err
}

View File

@@ -17,15 +17,22 @@ package d2vm
type ConvertOption func(o *convertOptions)
type convertOptions struct {
size int64
size uint64
password string
output string
cmdLineExtra string
networkManager NetworkManager
raw bool
splitBoot bool
bootSize uint64
luksPassword string
keepCache bool
}
func WithSize(size int64) ConvertOption {
func WithSize(size uint64) ConvertOption {
return func(o *convertOptions) {
o.size = size
}
@@ -60,3 +67,27 @@ func WithRaw(raw bool) ConvertOption {
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 WithLuksPassword(password string) ConvertOption {
return func(o *convertOptions) {
o.luksPassword = password
}
}
func WithKeepCache(b bool) ConvertOption {
return func(o *convertOptions) {
o.keepCache = 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,7 +134,7 @@ 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))

View File

@@ -64,6 +64,7 @@ type Dockerfile struct {
Password string
Release OSRelease
NetworkManager NetworkManager
Luks bool
tmpl *template.Template
}
@@ -71,13 +72,16 @@ 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) {
d := Dockerfile{Release: release, Image: img, Password: password, NetworkManager: networkManager}
func NewDockerfile(release OSRelease, img, password string, networkManager NetworkManager, luks bool) (Dockerfile, error) {
d := Dockerfile{Release: release, Image: img, Password: password, NetworkManager: networkManager, Luks: luks}
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,27 @@ d2vm build [context directory] [flags]
```
--append-to-cmdline string Extra kernel cmdline arguments to append to the generated one
--boot-size uint Size of the boot partition in MB (default 100)
--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 Optional root user password
--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,26 @@ 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-size uint Size of the boot partition in MB (default 100)
--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 Optional root user password
--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

@@ -20,7 +20,7 @@ d2vm run hetzner [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

@@ -12,9 +12,7 @@ d2vm run qemu [options] [image-path] [flags]
--accel string Choose acceleration mode. Use 'tcg' to disable it. (default "hvf:tcg")
--arch string Type of architecture to use, e.g. x86_64, aarch64, s390x (default "x86_64")
--cpus uint Number of CPUs (default 1)
--data string String of metadata to pass to VM
--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 +20,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
```

163
e2e/e2e_test.go Normal file
View File

@@ -0,0 +1,163 @@
// 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"
"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
}
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"},
}
func TestConvert(t *testing.T) {
require := require2.New(t)
tests := []test{
{
name: "single-partition",
},
{
name: "split-boot",
args: []string{"--split-boot"},
},
{
name: "luks",
args: []string{"--luks-password=root"},
},
}
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 images {
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()
}
}()
if err := qemu.Run(ctx, out, qemu.WithStdin(inr), qemu.WithStdout(io.MultiWriter(outw, os.Stdout)), qemu.WithStderr(io.Discard), qemu.WithMemory(2048)); err != nil && !success.Load() {
t.Fatalf("failed to run qemu: %v", err)
}
})
}
})
}
}

73
go.mod
View File

@@ -1,53 +1,52 @@
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/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.1
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.0.0-20210921155107-089bfa567519
golang.org/x/sys v0.7.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/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/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
@@ -55,16 +54,16 @@ require (
github.com/prometheus/common v0.26.0 // indirect
github.com/prometheus/procfs v0.6.0 // 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.8.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.8.0 // indirect
google.golang.org/protobuf v1.29.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
)

1345
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"text/template"
@@ -34,6 +35,7 @@ const (
ReleaseAlpine Release = "alpine"
ReleaseCentOS Release = "centos"
ReleaseRHEL Release = "rhel"
ReleaseKali Release = "kali"
)
type Release string
@@ -44,6 +46,8 @@ func (r Release) Supported() bool {
return true
case ReleaseDebian:
return true
case ReleaseKali:
return true
case ReleaseAlpine:
return true
case ReleaseCentOS:
@@ -63,6 +67,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 {

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

@@ -92,12 +92,19 @@ func ImageList(ctx context.Context, tag string) ([]string, error) {
return imgs, s.Err()
}
func ImageSave(ctx context.Context, tag, file string) error {
return Cmd(ctx, "image", "save", "-o", file, tag)
}
func Pull(ctx context.Context, tag string) error {
return Cmd(ctx, "image", "pull", 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 +134,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 +156,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...)

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

@@ -0,0 +1,141 @@
// 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
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 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
}
}

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

@@ -0,0 +1,363 @@
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())
// 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,
)
}

View File

@@ -6,7 +6,14 @@ RUN apk update --no-cache && \
apk add \
util-linux \
linux-virt \
{{- if ge .Release.VersionID "3.17" }}
busybox-openrc \
busybox-mdev-openrc \
busybox-extras-openrc \
busybox-mdev-openrc \
{{- else }}
busybox-initscripts \
{{- end }}
openrc
RUN for s in bootmisc hostname hwclock modules networking swap sysctl urandom syslog; do rc-update add $s boot; done
@@ -22,3 +29,10 @@ 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 }}

View File

@@ -7,14 +7,24 @@ 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 && \
RUN yum install -y \
kernel \
systemd \
NetworkManager \
e2fsprogs \
sudo && \
systemctl enable NetworkManager && \
systemctl unmask systemd-remount-fs.service && \
systemctl unmask getty.target
RUN dracut --no-hostonly --regenerate-all --force && \
systemctl unmask getty.target && \
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 }}
{{ if .Password }}RUN echo "root:{{ .Password }}" | chpasswd {{ end }}

View File

@@ -41,3 +41,10 @@ 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 }}

View File

@@ -40,3 +40,8 @@ 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 }}

View File

@@ -14,8 +14,17 @@
package d2vm
import (
"go.linka.cloud/d2vm/pkg/qemu_img"
)
var (
Version = ""
BuildDate = ""
Image = ""
Image = "linkacloud/d2vm"
)
func init() {
qemu_img.DockerImageName = Image
qemu_img.DockerImageVersion = Version
}