mirror of
https://github.com/lima-vm/lima.git
synced 2026-10-28 11:44:35 +08:00
initial commit
Signed-off-by: Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp>
This commit is contained in:
61 files changed
+4921
No files matched your search
@@ -0,0 +1,50 @@
|
||||
# Forked from https://github.com/containerd/nerdctl/blob/v0.8.1/.github/workflows/release.yml
|
||||
# Apache License 2.0
|
||||
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'test-action-release-*'
|
||||
env:
|
||||
GO111MODULE: on
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.16.x
|
||||
- uses: actions/checkout@v2
|
||||
- name: "Compile binaries"
|
||||
run: make artifacts
|
||||
- name: "SHA256SUMS"
|
||||
run: |
|
||||
( cd _artifacts; sha256sum *.tar.gz ) | tee /tmp/SHA256SUMS
|
||||
mv /tmp/SHA256SUMS _artifacts/SHA256SUMS
|
||||
- name: "The sha256sum of the SHA256SUMS file"
|
||||
run: (cd _artifacts; sha256sum SHA256SUMS)
|
||||
- name: "Prepare the release note"
|
||||
run: |
|
||||
tag="${GITHUB_REF##*/}"
|
||||
shasha=$(sha256sum _artifacts/SHA256SUMS | awk '{print $1}')
|
||||
cat <<-EOF | tee /tmp/release-note.txt
|
||||
${tag}
|
||||
|
||||
(Changes to be documented)
|
||||
- - -
|
||||
The binaries were built automatically on GitHub Actions.
|
||||
The build log is available for 90 days: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
The sha256sum of the SHA256SUMS file itself is \`${shasha}\` .
|
||||
EOF
|
||||
- name: "Create release"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF##*/}"
|
||||
asset_flags=()
|
||||
for f in _artifacts/*; do asset_flags+=("-a" "$f"); done
|
||||
hub release create "${asset_flags[@]}" -F /tmp/release-note.txt --draft "${tag}"
|
||||
@@ -0,0 +1,84 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- 'release/**'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
project:
|
||||
name: Project Checks
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.16.x
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 25
|
||||
- name: "Check DCO sign"
|
||||
run: |
|
||||
go get -u github.com/vbatts/git-validation
|
||||
./hack/validate-dco.sh
|
||||
|
||||
golangci-lint:
|
||||
runs-on: macos-10.15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
version: v1.35
|
||||
args: --verbose
|
||||
|
||||
unit:
|
||||
name: Unit tests
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-10.15, ubuntu-20.04]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.16.x
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 25
|
||||
- name: Unit tests
|
||||
run: go test -v ./...
|
||||
|
||||
integration:
|
||||
name: Integration (WIP)
|
||||
runs-on: macos-10.15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.16.x
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 25
|
||||
- name: Make
|
||||
run: make
|
||||
|
||||
artifacts:
|
||||
name: Artifacts
|
||||
# the release pipeline uses Linux, so we Linux here as well
|
||||
runs-on: ubuntu-20.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.16.x
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 25
|
||||
- name: Make artifacts
|
||||
run: make artifacts
|
||||
@@ -0,0 +1,3 @@
|
||||
_output/
|
||||
_artifacts/
|
||||
lima.REJECTED.yaml
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,62 @@
|
||||
GO ?= go
|
||||
|
||||
TAR ?= tar
|
||||
|
||||
PACKAGE := github.com/AkihiroSuda/lima
|
||||
|
||||
VERSION=$(shell git describe --match 'v[0-9]*' --dirty='.m' --always --tags)
|
||||
VERSION_TRIMMED := $(VERSION:v%=%)
|
||||
|
||||
GO_BUILD := CGO_ENABLED=0 $(GO) build -ldflags="-s -w -X $(PACKAGE)/pkg/version.Version=$(VERSION)"
|
||||
|
||||
.PHONY: all
|
||||
all: binaries
|
||||
|
||||
.PHONY: binaries
|
||||
binaries: \
|
||||
_output/bin/lima \
|
||||
_output/bin/limactl \
|
||||
_output/bin/nerdctl.lima \
|
||||
_output/share/lima/lima-guestagent.Linux-x86_64 \
|
||||
_output/share/lima/lima-guestagent.Linux-aarch64
|
||||
|
||||
.PHONY: _output/bin/lima
|
||||
_output/bin/lima:
|
||||
mkdir -p _output/bin
|
||||
cp -a ./cmd/lima $@
|
||||
|
||||
.PHONY: _output/bin/nerdctl.lima
|
||||
_output/bin/nerdctl.lima:
|
||||
mkdir -p _output/bin
|
||||
cp -a ./cmd/nerdctl.lima $@
|
||||
|
||||
.PHONY: _output/bin/limactl
|
||||
_output/bin/limactl:
|
||||
$(GO_BUILD) -o $@ ./cmd/limactl
|
||||
|
||||
.PHONY: _output/share/lima/lima-guestagent.Linux-x86_64
|
||||
_output/share/lima/lima-guestagent.Linux-x86_64:
|
||||
GOOS=linux GOARCH=amd64 $(GO_BUILD) -o $@ ./cmd/lima-guestagent
|
||||
chmod 644 $@
|
||||
|
||||
.PHONY: _output/share/lima/lima-guestagent.Linux-aarch64
|
||||
_output/share/lima/lima-guestagent.Linux-aarch64:
|
||||
GOOS=linux GOARCH=arm64 $(GO_BUILD) -o $@ ./cmd/lima-guestagent
|
||||
chmod 644 $@
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
cp -av _output/* /usr/local/
|
||||
if [ ! -e /usr/local/bin/nerdctl ]; then ln -sf nerdctl.lima /usr/local/bin/nerdctl; fi
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf _output
|
||||
|
||||
.PHONY: artifacts
|
||||
artifacts:
|
||||
mkdir -p _artifacts
|
||||
GOOS=darwin GOARCH=amd64 make clean binaries
|
||||
$(TAR) -C _output/ -czvf _artifacts/lima-$(VERSION_TRIMMED)-Darwin-x86_64.tar.gz ./
|
||||
GOOS=darwin GOARCH=arm64 make clean binaries
|
||||
$(TAR) -C _output -czvf _artifacts/lima-$(VERSION_TRIMMED)-Darwin-arm64.tar.gz ./
|
||||
@@ -0,0 +1,223 @@
|
||||
# Lima: Linux-on-Mac ("macOS subsystem for Linux", "containerd for Mac")
|
||||
|
||||
Lima launches Linux virtual machines on macOS, with automatic file sharing, port forwarding,
|
||||
and [containerd](https://containerd.io).
|
||||
|
||||
Lima can be considered as a some sort of unofficial "macOS subsystem for Linux", or "containerd for Mac".
|
||||
|
||||
✅ Automatic file sharing
|
||||
|
||||
✅ Automatic port forwarding
|
||||
|
||||
✅ Built-in support for [containerd](https://containerd.io)
|
||||
|
||||
✅ Intel on Intel
|
||||
|
||||
✅ ARM on Intel
|
||||
|
||||
✅ ARM on ARM (untested)
|
||||
|
||||
✅ Intel on ARM (untested)
|
||||
|
||||
✅ Ubuntu
|
||||
|
||||
✅ Fedora
|
||||
|
||||
Related project: [sshocker (ssh with file sharing and port forwarding)](https://github.com/AkihiroSuda/sshocker)
|
||||
|
||||
## Examples
|
||||
|
||||
### uname
|
||||
```console
|
||||
$ uname -a
|
||||
Darwin macbook.local 20.4.0 Darwin Kernel Version 20.4.0: Thu Apr 22 21:46:47 PDT 2021; root:xnu-7195.101.2~1/RELEASE_X86_64 x86_64
|
||||
|
||||
$ lima uname -a
|
||||
Linux lima-default 5.11.0-16-generic #17-Ubuntu SMP Wed Apr 14 20:12:43 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
|
||||
|
||||
$ LIMA_INSTANCE=arm lima uname -a
|
||||
Linux lima-arm 5.11.0-16-generic #17-Ubuntu SMP Wed Apr 14 20:10:16 UTC 2021 aarch64 aarch64 aarch64 GNU/Linux
|
||||
```
|
||||
|
||||
### Sharing files across macOS and Linux
|
||||
```console
|
||||
$ echo "files under /Users on macOS filesystem are readable from Linux" > some-file
|
||||
|
||||
$ lima cat some-file
|
||||
files under /Users on macOS filesystem are readable from Linux
|
||||
|
||||
$ lima sh -c 'echo "/tmp/lima is writable from both macOS and Linux" > /tmp/lima/another-file'
|
||||
|
||||
$ cat /tmp/lima/another-file
|
||||
/tmp/lima is writable from both macOS and Linux"
|
||||
```
|
||||
|
||||
### Running containerd containers (compatible with Docker containers)
|
||||
```console
|
||||
$ lima nerdctl run -d --name nginx -p 127.0.0.1:8080:80 nginx:alpine
|
||||
```
|
||||
|
||||
http://127.0.0.1:8080 is accessible from both macOS and Linux.
|
||||
|
||||
> **NOTE**
|
||||
> Privileged ports (0-1023) cannot be forwarded
|
||||
|
||||
For the usage of containerd and nerdctl (contaiNERD ctl), visit https://github.com/containerd/containerd and https://github.com/containerd/nerdctl.
|
||||
|
||||
## Getting started
|
||||
### Requirements (Intel Mac)
|
||||
- QEMU (`brew install qemu`)
|
||||
|
||||
- Run the following commands to enable `--accel=hvf`:
|
||||
```bash
|
||||
cat >entitlements.xml <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- for OS X 10.10 - macOS 10.15 -->
|
||||
<key>com.apple.vm.hypervisor</key>
|
||||
<true/>
|
||||
<!-- for macOS 11 and later -->
|
||||
<key>com.apple.security.hypervisor</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
codesign -s - --entitlements entitlements.xml --force /usr/local/bin/qemu-system-x86_64
|
||||
```
|
||||
|
||||
### Requirements (ARM Mac)
|
||||
|
||||
- QEMU with `--accel=hvf` support, see https://gist.github.com/citruz/9896cd6fb63288ac95f81716756cb9aa
|
||||
|
||||
> **NOTE**
|
||||
> Lima is not tested on ARM Mac.
|
||||
|
||||
### Install
|
||||
|
||||
Download the binary archive from https://github.com/AkihiroSuda/lima/releases ,
|
||||
and extract it under `/usr/local` (or somewhere else).
|
||||
|
||||
To install from the source, run `make && make install`.
|
||||
|
||||
### Usage
|
||||
- Run `limactl start <INSTANCE>` to start the Linux instance.
|
||||
The default instance name is "default".
|
||||
Lima automatically opens an editor (`vi`) for reviewing and modifying the configuration.
|
||||
Wait until "READY" to be printed on the host terminal.
|
||||
|
||||
- Run `limactl shell <INSTANCE> <COMMAND>` to launch `<COMMAND>` on Linux.
|
||||
For the "default" instance, this command can be shortened as just `lima <COMMAND>`.
|
||||
The `lima` command also accepts the instance name as the environment variable `$LIMA_INSTANCE`.
|
||||
|
||||
- Run `limactl ls` to show the instances.
|
||||
|
||||
- Run `limactl delete <INSTANCE>` to delete the instance.
|
||||
|
||||
- To enable bash completion, add `source <(limactl completion bash)` to `~/.bash_profile`.
|
||||
|
||||
### :warning: CAUTION: make sure to back up your data
|
||||
Lima may have bugs that result in loss of data.
|
||||
|
||||
**Make sure to back up your data before running Lima.**
|
||||
|
||||
Especially, the following data might be easily lost:
|
||||
- Data in the shared writable directories (`/tmp/lima` by default),
|
||||
probably after hibernation of the host machine (e.g., after closing and reopening the laptop lid)
|
||||
- Data in the VM image, mostly when upgrading the version of lima
|
||||
|
||||
### Configuration
|
||||
|
||||
See [`./pkg/limayaml/default.TEMPLATE.yaml`](./pkg/limayaml/default.TEMPLATE.yaml).
|
||||
|
||||
The current default spec:
|
||||
- OS: Ubuntu 21.04 (Hirsute Hippo)
|
||||
- CPU (x86\_64): Haswell v4, 4 cores
|
||||
- CPU (aarch64): Cortex A72, 4 cores
|
||||
- Memory: 4 GiB
|
||||
- Disk: 100 GiB
|
||||
- Mounts: `~` (read-only), `/tmp/lima` (writable)
|
||||
- SSH: 127.0.0.1:60022
|
||||
|
||||
## How it works
|
||||
|
||||
- Hypervisor: QEMU with HVF accelerator
|
||||
- Filesystem sharing: [reverse sshfs](https://github.com/AkihiroSuda/sshocker/blob/v0.1.0/pkg/reversesshfs/reversesshfs.go)
|
||||
- Port forwarding: `ssh -L`, automated by watching `/proc/net/tcp` in the guest
|
||||
|
||||
## Developer guide
|
||||
|
||||
### Contributing to Lima
|
||||
- Please certify your [Developer Certificate of Origin (DCO)](https://developercertificate.org/),
|
||||
by signing off your commit with `git commit -s` and with your real name.
|
||||
|
||||
- Please squash commits.
|
||||
|
||||
### Help wanted
|
||||
:pray:
|
||||
- Test on ARM Mac
|
||||
- Performance optimization
|
||||
- Homebrew
|
||||
- More guest distros
|
||||
- Linux on Windows/Linux/BSD
|
||||
- GUI with system tray icon (Qt or Electron, for portability)
|
||||
- VirtFS to replace the current reverse sshfs (work has to be done on QEMU repo)
|
||||
- [vsock](https://github.com/apple/darwin-xnu/blob/xnu-7195.81.3/bsd/man/man4/vsock.4) to replace SSH (work has to be done on QEMU repo)
|
||||
|
||||
## FAQs
|
||||
### "What's my login password?"
|
||||
Password is disabled and locked by default.
|
||||
You have to use `limactl shell bash` (or `lima bash`) to open a shell.
|
||||
|
||||
Alternatively, you may also directly ssh into the guest: `ssh -p 60022 -o NoHostAuthenticationForLocalhost=yes 127.0.0.1`.
|
||||
|
||||
### "Does Lima work on ARM Mac?"
|
||||
Yes, it should work, but not tested on ARM.
|
||||
|
||||
### "Can I run non-Ubuntu guests?"
|
||||
Fedora is also known to work, see [`./examples/fedora.yaml`](./examples/fedora.yaml).
|
||||
This file can be loaded with `limactl start ./examples/fedora.yaml`.
|
||||
|
||||
An image has to satisfy the following requirements:
|
||||
- systemd
|
||||
- cloud-init
|
||||
- The following binaries to be preinstalled:
|
||||
- `curl`
|
||||
- `sudo`
|
||||
- The following binaries to be preinstalled, or installable via the package manager:
|
||||
- `sshfs`
|
||||
- `newuidmap` and `newgidmap`
|
||||
- `apt-get` or `dnf` (if you want to contribute support for another package manager, run `git grep apt-get` to find out where to modify)
|
||||
|
||||
### "Can I run other container engines such as Podman?"
|
||||
Yes, if you install it.
|
||||
|
||||
containerd can be stopped with `systemctl --user disable --now containerd`.
|
||||
|
||||
### "Can I run Lima with a remote Linux machine?"
|
||||
Lima itself does not support connecting to a remote Linux machine, but [sshocker](https://github.com/AkihiroSuda/sshocker),
|
||||
the predecessor or Lima, provides similar features for remote Linux machines.
|
||||
|
||||
e.g., run `sshocker -v /Users/foo:/home/foo/mnt -p 8080:80 <USER>@<HOST>` to expose `/Users/foo` to the remote machine as `/home/foo/mnt`,
|
||||
and forward `localhost:8080` to the port 80 of the remote machine.
|
||||
|
||||
### "QEMU crashes with `HV_ERROR`"
|
||||
You have to add `com.apple.security.hypervisor` entitlement to `qemu-system-x86_64` binary.
|
||||
See [Getting started](#getting-started).
|
||||
|
||||
### "QEMU is slow"
|
||||
- Make sure that HVF is enabled with `com.apple.security.hypervisor` entitlement. See [Getting started](#getting-started).
|
||||
- Emulating non-native machines (ARM-on-Intel, Intel-on-ARM) is slow by design.
|
||||
|
||||
### "Port forwarding does not work"
|
||||
Privileged ports (0-1023) cannot be forwarded. e.g., you have to use 8080, not 80.
|
||||
|
||||
### "Hints for debugging other problems?"
|
||||
- Inspect logs:
|
||||
- `limactl --debug start`
|
||||
- `/var/log/cloud-init-output.log` (inside the guest)
|
||||
- `/var/log/cloud-init.log` (inside the guest)
|
||||
- Make sure that you aren't mixing up tabs and spaces in the YAML.
|
||||
- If you have passphrases for any private key under `~/.ssh`, you will need to have `ssh-agent` running.
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
: "${LIMA_INSTANCE:=default}"
|
||||
LIMA="$(realpath "$0")"
|
||||
LIMA_BINDIR="$(dirname "$LIMA")"
|
||||
LIMACTL="${LIMA_BINDIR}/limactl"
|
||||
if [ -d "$LIMACTL" ]; then
|
||||
LIMACTL="limactl"
|
||||
fi
|
||||
exec "$LIMACTL" shell "$LIMA_INSTANCE" "$@"
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent"
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent/api/server"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var daemonCommand = &cli.Command{
|
||||
Name: "daemon",
|
||||
Usage: "run the daemon",
|
||||
ArgsUsage: "[SOCKET]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "socket",
|
||||
Usage: "socket",
|
||||
Value: func() string {
|
||||
if xrd := os.Getenv("XDG_RUNTIME_DIR"); xrd != "" {
|
||||
return filepath.Join(xrd, "lima-guestagent.sock")
|
||||
}
|
||||
logrus.Warn("$XDG_RUNTIME_DIR is not set, cannot determine the socket name")
|
||||
return ""
|
||||
}(),
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "tick",
|
||||
Usage: "tick for polling events",
|
||||
Value: 3 * time.Second,
|
||||
},
|
||||
},
|
||||
Action: daemonAction,
|
||||
}
|
||||
|
||||
func daemonAction(clicontext *cli.Context) error {
|
||||
socket := clicontext.String("socket")
|
||||
if socket == "" {
|
||||
return errors.New("socket must be specified")
|
||||
}
|
||||
tick := clicontext.Duration("tick")
|
||||
if tick == 0 {
|
||||
return errors.New("tick must be specified")
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
return errors.New("must not run as the root")
|
||||
}
|
||||
logrus.Infof("event tick: %v", tick)
|
||||
|
||||
newTicker := func() (<-chan time.Time, func()) {
|
||||
// TODO: use an equivalent of `bpftrace -e 'tracepoint:syscalls:sys_*_bind { printf("tick\n"); }')`,
|
||||
// without depending on `bpftrace` binary.
|
||||
// The agent binary will need CAP_BPF file cap.
|
||||
ticker := time.NewTicker(tick)
|
||||
return ticker.C, ticker.Stop
|
||||
}
|
||||
|
||||
agent := guestagent.New(newTicker)
|
||||
backend := &server.Backend{
|
||||
Agent: agent,
|
||||
}
|
||||
r := mux.NewRouter()
|
||||
server.AddRoutes(r, backend)
|
||||
srv := &http.Server{Handler: r}
|
||||
err := os.RemoveAll(socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Infof("serving the guest agent on %q", socket)
|
||||
return srv.Serve(l)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/templateutil"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var installSystemdCommand = &cli.Command{
|
||||
Name: "install-systemd",
|
||||
Usage: "install a systemd unit (user)",
|
||||
Action: installSystemdAction,
|
||||
}
|
||||
|
||||
func installSystemdAction(clicontext *cli.Context) error {
|
||||
unit, err := generateSystemdUnit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unitPath, err := systemdUnitPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(unitPath); !errors.Is(err, os.ErrNotExist) {
|
||||
logrus.Infof("File %q already exists, overwriting", unitPath)
|
||||
} else {
|
||||
unitDir := filepath.Dir(unitPath)
|
||||
if err := os.MkdirAll(unitDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(unitPath, unit, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Infof("Written file %q", unitPath)
|
||||
argss := [][]string{
|
||||
{"daemon-reload"},
|
||||
{"enable", "--now", "lima-guestagent.service"},
|
||||
}
|
||||
for _, args := range argss {
|
||||
cmd := exec.Command("systemctl", append([]string{"--user"}, args...)...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
logrus.Infof("Executing: %s", strings.Join(cmd.Args, " "))
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
logrus.Info("Done")
|
||||
return nil
|
||||
}
|
||||
|
||||
func systemdUnitPath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
unitPath := filepath.Join(configDir, "systemd/user/lima-guestagent.service")
|
||||
return unitPath, nil
|
||||
}
|
||||
|
||||
//go:embed lima-guestagent.TEMPLATE.service
|
||||
var systemdUnitTemplate string
|
||||
|
||||
func generateSystemdUnit() ([]byte, error) {
|
||||
selfExeAbs, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := map[string]string{
|
||||
"Binary": selfExeAbs,
|
||||
}
|
||||
return templateutil.Execute(systemdUnitTemplate, m)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=lima-guestagent
|
||||
|
||||
[Service]
|
||||
ExecStart={{.Binary}} daemon
|
||||
Type=simple
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/version"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := newApp().Run(os.Args); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func newApp() *cli.App {
|
||||
app := cli.NewApp()
|
||||
app.Name = "lima-guestagent"
|
||||
app.Usage = "Do not launch manually"
|
||||
app.Version = strings.TrimPrefix(version.Version, "v")
|
||||
app.Flags = []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Usage: "debug mode",
|
||||
},
|
||||
}
|
||||
app.Before = func(clicontext *cli.Context) error {
|
||||
if clicontext.Bool("debug") {
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
return errors.New("must not run as the root")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
app.Commands = []*cli.Command{
|
||||
daemonCommand,
|
||||
installSystemdCommand,
|
||||
}
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Forked from https://github.com/containerd/nerdctl/blob/v0.8.1/completion.go
|
||||
|
||||
/*
|
||||
Copyright The containerd Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/store"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var completionCommand = &cli.Command{
|
||||
Name: "completion",
|
||||
Usage: "Show shell completion",
|
||||
Subcommands: []*cli.Command{
|
||||
completionBashCommand,
|
||||
},
|
||||
}
|
||||
|
||||
var completionBashCommand = &cli.Command{
|
||||
Name: "bash",
|
||||
Usage: "Show bash completion (use with `source <(limactl completion bash)`)",
|
||||
Description: "Usage: add `source <(limactl completion bash)` to ~/.bash_profile",
|
||||
Action: completionBashAction,
|
||||
}
|
||||
|
||||
func completionBashAction(clicontext *cli.Context) error {
|
||||
tmpl := `#!/bin/bash
|
||||
# Autocompletion enabler for limactl.
|
||||
# Usage: add 'source <(limactl completion bash)' to ~/.bash_profile
|
||||
|
||||
# _limactl_bash_autocomplete is forked from https://github.com/urfave/cli/blob/v2.3.0/autocomplete/bash_autocomplete (MIT License)
|
||||
_limactl_bash_autocomplete() {
|
||||
if [[ "${COMP_WORDS[0]}" != "source" ]]; then
|
||||
local cur opts base
|
||||
COMPREPLY=()
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
local args="${COMP_WORDS[@]:0:$COMP_CWORD}"
|
||||
# make {"limactl", "--foo", "=", "bar"} into {"limactl", "--foo=bar"}
|
||||
args="$(echo $args | sed -e 's/ = /=/g')"
|
||||
if [[ "$cur" == "-"* ]]; then
|
||||
opts=$( ${args} ${cur} --generate-bash-completion )
|
||||
else
|
||||
opts=$( ${args} --generate-bash-completion )
|
||||
fi
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
complete -o bashdefault -o default -o nospace -F _limactl_bash_autocomplete limactl
|
||||
`
|
||||
_, err := fmt.Fprint(clicontext.App.Writer, tmpl)
|
||||
return err
|
||||
}
|
||||
|
||||
func bashCompleteInstanceNames(clicontext *cli.Context) {
|
||||
w := clicontext.App.Writer
|
||||
instances, err := store.Instances()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, name := range instances {
|
||||
fmt.Fprintln(w, name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/store"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var deleteCommand = &cli.Command{
|
||||
Name: "delete",
|
||||
Usage: "Delete an instance of Lima.",
|
||||
ArgsUsage: "INSTANCE [INSTANCE, ...]",
|
||||
Action: deleteAction,
|
||||
BashComplete: deleteBashComplete,
|
||||
}
|
||||
|
||||
func deleteAction(clicontext *cli.Context) error {
|
||||
if clicontext.NArg() == 0 {
|
||||
return errors.Errorf("requires at least 1 argument")
|
||||
}
|
||||
for _, instName := range clicontext.Args().Slice() {
|
||||
instDir, err := store.InstanceDir(instName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(instDir); errors.Is(err, os.ErrNotExist) {
|
||||
logrus.Warnf("Ignoring non-existent instance %q (%q)", instName, instDir)
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(instDir); err != nil {
|
||||
return errors.Wrapf(err, "failed to remove %q", instDir)
|
||||
}
|
||||
logrus.Infof("Deleted %q (%q)", instName, instDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteBashComplete(clicontext *cli.Context) {
|
||||
bashCompleteInstanceNames(clicontext)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/store"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var lsCommand = &cli.Command{
|
||||
Name: "ls",
|
||||
Usage: "List instances of Lima.",
|
||||
// TODO: add --json flag for automation
|
||||
Action: lsAction,
|
||||
}
|
||||
|
||||
func lsAction(clicontext *cli.Context) error {
|
||||
instances, err := store.Instances()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(clicontext.App.Writer, 4, 8, 4, ' ', 0)
|
||||
fmt.Fprintln(w, "NAME\tSSH\tARCH\tDIR")
|
||||
|
||||
if len(instances) == 0 {
|
||||
logrus.Warn("No instance found. Run `limactl start` to create an instance.")
|
||||
}
|
||||
|
||||
for _, instName := range instances {
|
||||
y, instDir, err := store.LoadYAMLByInstanceName(instName)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Warnf("failed to load instance %q", instName)
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
||||
instName,
|
||||
fmt.Sprintf("127.0.0.1:%d", y.SSH.LocalPort),
|
||||
y.Arch,
|
||||
instDir,
|
||||
)
|
||||
}
|
||||
|
||||
return w.Flush()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/version"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := newApp().Run(os.Args); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func newApp() *cli.App {
|
||||
app := cli.NewApp()
|
||||
app.Name = "limactl"
|
||||
app.Usage = `Lima: Linux-on-Mac ("macOS subsystem for Linux", "containerd for Mac")`
|
||||
app.UseShortOptionHandling = true
|
||||
app.EnableBashCompletion = true
|
||||
app.BashComplete = appBashComplete
|
||||
app.Version = strings.TrimPrefix(version.Version, "v")
|
||||
app.Flags = []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Usage: "debug mode",
|
||||
},
|
||||
}
|
||||
app.Before = func(clicontext *cli.Context) error {
|
||||
if clicontext.Bool("debug") {
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
return errors.New("must not run as the root")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
app.Commands = []*cli.Command{
|
||||
startCommand,
|
||||
// TODO: add stopCommand (stops an instance without deletion)
|
||||
shellCommand,
|
||||
lsCommand,
|
||||
deleteCommand,
|
||||
completionCommand,
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultInstanceName = "default"
|
||||
)
|
||||
|
||||
func appBashComplete(clicontext *cli.Context) {
|
||||
w := clicontext.App.Writer
|
||||
cli.DefaultAppComplete(clicontext)
|
||||
for _, subcomm := range clicontext.App.Commands {
|
||||
fmt.Fprintln(w, subcomm.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/sshutil"
|
||||
"github.com/AkihiroSuda/lima/pkg/store"
|
||||
"github.com/alessio/shellescape"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var shellCommand = &cli.Command{
|
||||
Name: "shell",
|
||||
Usage: "Execute shell in Lima",
|
||||
ArgsUsage: "[flags] INSTANCE [COMMAND...]",
|
||||
Description: "`lima` command is provided as an alias for `limactl shell $LIMA_INSTANCE`. $LIMA_INSTANCE defaults to " + DefaultInstanceName + ".\n" +
|
||||
"Hint: try --debug to show the detailed logs, if it seems hanging (mostly due to some SSH issue).",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "workdir",
|
||||
Usage: "working directory",
|
||||
Value: func() string {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Warn("failed to get the current directory")
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Warn("failed to get the home directory")
|
||||
return "/"
|
||||
}
|
||||
return home
|
||||
}
|
||||
return wd
|
||||
}(),
|
||||
},
|
||||
},
|
||||
Action: shellAction,
|
||||
BashComplete: shellBashComplete,
|
||||
}
|
||||
|
||||
func shellAction(clicontext *cli.Context) error {
|
||||
if clicontext.NArg() == 0 {
|
||||
return errors.Errorf("requires at least 1 argument")
|
||||
}
|
||||
instName := clicontext.Args().First()
|
||||
|
||||
switch clicontext.Args().Get(1) {
|
||||
case "start", "delete", "shell":
|
||||
// `lima start` (alias of `limactl $LIMA_INSTANCE start`) is probably a typo of `limactl start`
|
||||
logrus.Warnf("Perhaps you meant `limactl %s`?", strings.Join(clicontext.Args().Slice(), " "))
|
||||
}
|
||||
|
||||
hostHome, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(" cd %q || cd %q ; exec bash --login", clicontext.String("workdir"), hostHome)
|
||||
if clicontext.NArg() > 1 {
|
||||
script += fmt.Sprintf(" -c %q", shellescape.QuoteCommand(clicontext.Args().Tail()))
|
||||
}
|
||||
|
||||
y, instDir, err := store.LoadYAMLByInstanceName(instName)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return errors.Errorf("instance %q does not exist, run `limactl start %s` to create a new instance", instName, instName)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
arg0, err := exec.LookPath("ssh")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args, err := sshutil.SSHArgs(instDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isatty.IsTerminal(os.Stdout.Fd()) {
|
||||
// required for showing the shell prompt: https://stackoverflow.com/a/626574
|
||||
args = append(args, "-t")
|
||||
}
|
||||
args = append(args, []string{
|
||||
"-q",
|
||||
"-p", strconv.Itoa(y.SSH.LocalPort),
|
||||
"127.0.0.1",
|
||||
"--",
|
||||
script,
|
||||
}...)
|
||||
cmd := exec.Command(arg0, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
logrus.Debugf("executing ssh (may take a long)): %+v", cmd.Args)
|
||||
|
||||
// TODO: use syscall.Exec directly (results in losing tty?)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func shellBashComplete(clicontext *cli.Context) {
|
||||
bashCompleteInstanceNames(clicontext)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/limayaml"
|
||||
"github.com/AkihiroSuda/lima/pkg/start"
|
||||
"github.com/AkihiroSuda/lima/pkg/store"
|
||||
"github.com/containerd/containerd/identifiers"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/norouter/norouter/cmd/norouter/editorcmd"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var startCommand = &cli.Command{
|
||||
Name: "start",
|
||||
Usage: fmt.Sprintf("Start an instance of Lima. If the instance does not exist, open an editor for creating new one, with name %q", DefaultInstanceName),
|
||||
ArgsUsage: "[flags] NAME|FILE.yaml",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "tty",
|
||||
Usage: "enable TUI interactions such as opening an editor, defaults to true when stdout is a terminal",
|
||||
Value: isatty.IsTerminal(os.Stdout.Fd()),
|
||||
},
|
||||
},
|
||||
Action: startAction,
|
||||
BashComplete: startBashComplete,
|
||||
}
|
||||
|
||||
func loadOrCreateYAML(clicontext *cli.Context) (y *limayaml.LimaYAML, instDir string, err error) {
|
||||
if clicontext.NArg() > 1 {
|
||||
return nil, "", errors.Errorf("too many arguments")
|
||||
}
|
||||
|
||||
arg := clicontext.Args().First()
|
||||
if arg == "" {
|
||||
arg = DefaultInstanceName
|
||||
}
|
||||
|
||||
yBytes := limayaml.DefaultTemplate
|
||||
var instName string
|
||||
|
||||
if argSeemsYAMLPath(arg) {
|
||||
instName = instNameFromYAMLPath(arg)
|
||||
logrus.Debugf("interpreting argument %q as a file path for instance %q", arg, instName)
|
||||
yBytes, err = os.ReadFile(arg)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
} else {
|
||||
instName = arg
|
||||
logrus.Debugf("interpreting argument %q as an instance name %q", arg, instName)
|
||||
if err = identifiers.Validate(instName); err != nil {
|
||||
return nil, "", errors.Wrapf(err, "argument must be either an instance name or a YAML file path, got %q", instName)
|
||||
}
|
||||
y, instDir, err = store.LoadYAMLByInstanceName(instName)
|
||||
if err == nil {
|
||||
logrus.Infof("Using the existing instance %q", instName)
|
||||
return y, instDir, nil
|
||||
} else {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
// create a new instance from the template
|
||||
instDir, err = store.InstanceDir(instName)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(instDir); !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", errors.Errorf("instance %q already exists (%q)", instName, instDir)
|
||||
}
|
||||
|
||||
if clicontext.Bool("tty") {
|
||||
yBytes, err = openEditor(clicontext, instName, yBytes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(yBytes) == 0 {
|
||||
logrus.Info("Aborting, as requested by saving the file with empty content")
|
||||
os.Exit(0)
|
||||
return nil, "", errors.New("should not reach here")
|
||||
}
|
||||
} else {
|
||||
logrus.Info("Terminal is not available, proceeding without opening an editor")
|
||||
}
|
||||
y, err = limayaml.Load(yBytes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := limayaml.Validate(*y); err != nil {
|
||||
if !clicontext.Bool("tty") {
|
||||
return nil, "", err
|
||||
}
|
||||
rejectedYAML := "lima.REJECTED.yaml"
|
||||
if writeErr := os.WriteFile(rejectedYAML, yBytes, 0644); writeErr != nil {
|
||||
return nil, "", errors.Wrapf(err, "the YAML is invalid, attempted to save the buffer as %q but failed: %v", rejectedYAML, writeErr)
|
||||
}
|
||||
return nil, "", errors.Wrapf(err, "the YAML is invalid, saved the buffer as %q", rejectedYAML)
|
||||
}
|
||||
if err := os.MkdirAll(instDir, 0700); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(instDir, store.YAMLFileName), yBytes, 0644); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return y, instDir, nil
|
||||
}
|
||||
|
||||
// openEditor opens an editor, and returns the content (not path) of the modified yaml.
|
||||
//
|
||||
// openEditor returns nil when the file was saved as an empty file, optionally with whitespaces.
|
||||
func openEditor(clicontext *cli.Context, name string, initialContent []byte) ([]byte, error) {
|
||||
editor := editorcmd.Detect()
|
||||
if editor == "" {
|
||||
return nil, errors.New("could not detect a text editor binary, try setting $EDITOR")
|
||||
}
|
||||
tmpYAMLFile, err := ioutil.TempFile("", "lima-editor-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpYAMLPath := tmpYAMLFile.Name()
|
||||
defer os.RemoveAll(tmpYAMLPath)
|
||||
hdr := fmt.Sprintf("# Review and modify the following configuration for Lima instance %q.\n", name)
|
||||
if name == DefaultInstanceName {
|
||||
hdr += "# - In most cases, you do not need to modify this file.\n"
|
||||
}
|
||||
hdr += "# - To cancel starting Lima, just save this file as an empty file.\n"
|
||||
hdr += "\n"
|
||||
if err := ioutil.WriteFile(tmpYAMLPath,
|
||||
append([]byte(hdr), initialContent...),
|
||||
0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
editorCmd := exec.Command(editor, tmpYAMLPath)
|
||||
editorCmd.Env = os.Environ()
|
||||
editorCmd.Stdin = os.Stdin
|
||||
editorCmd.Stdout = os.Stdout
|
||||
editorCmd.Stderr = os.Stderr
|
||||
logrus.Debugf("opening editor %q for a file %q", editor, tmpYAMLPath)
|
||||
if err := editorCmd.Run(); err != nil {
|
||||
return nil, errors.Wrapf(err, "could not execute editor %q for a file %q", editor, tmpYAMLPath)
|
||||
}
|
||||
b, err := os.ReadFile(tmpYAMLPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modifiedInclHdr := string(b)
|
||||
modifiedExclHdr := strings.TrimPrefix(modifiedInclHdr, hdr)
|
||||
if strings.TrimSpace(modifiedExclHdr) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(modifiedExclHdr), nil
|
||||
}
|
||||
|
||||
func startAction(clicontext *cli.Context) error {
|
||||
y, instDir, err := loadOrCreateYAML(clicontext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instName, err := store.InstanceNameFromInstanceDir(instDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := clicontext.Context
|
||||
return start.Start(ctx, instName, instDir, y)
|
||||
}
|
||||
|
||||
func argSeemsYAMLPath(arg string) bool {
|
||||
if strings.Contains(arg, "/") {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(arg)
|
||||
return strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml")
|
||||
}
|
||||
|
||||
func instNameFromYAMLPath(yamlPath string) string {
|
||||
s := strings.ToLower(filepath.Base(yamlPath))
|
||||
s = strings.TrimSuffix(strings.TrimSuffix(s, ".yml"), ".yaml")
|
||||
s = strings.ReplaceAll(s, ".", "-")
|
||||
if err := identifiers.Validate(s); err != nil {
|
||||
logrus.WithField("candidate", s).WithError(err).
|
||||
Warnf("failed to determine the name of the new instance from file path %q, using the default name %q",
|
||||
yamlPath, DefaultInstanceName)
|
||||
return DefaultInstanceName
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func startBashComplete(clicontext *cli.Context) {
|
||||
bashCompleteInstanceNames(clicontext)
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
exec lima nerdctl "$@"
|
||||
@@ -0,0 +1,13 @@
|
||||
# Internal data structure
|
||||
|
||||
## Instance directory (`~/.lima/<INSTANCE>`)
|
||||
|
||||
An instance directory contains the following files:
|
||||
|
||||
- `lima.yaml`: the YAML
|
||||
- `cidata.iso`: cloud-init ISO9660 image. (`user-data`, `meta-data`, `lima-guestagent.Linux-<ARCH>`)
|
||||
- `basedisk`: the base image
|
||||
- `diffdisk`: the diff image (QCOW2)
|
||||
- `qemu-pid`: PID of the QEMU
|
||||
- `ssh.sock`: SSH control master socket
|
||||
- `ga.sock`: Forwarded to `/run/user/$UID/lima-guestagent.sock`
|
||||
@@ -0,0 +1,5 @@
|
||||
# Lima examples
|
||||
|
||||
Run `limactl start fedora.yaml` to create a Lima instance named "fedora".
|
||||
|
||||
To open a shell, run `limactl shell fedora bash` or `LIMA_INSTANCE=fedora lima bash`.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../pkg/limayaml/default.TEMPLATE.yaml
|
||||
@@ -0,0 +1,16 @@
|
||||
arch: "x86_64"
|
||||
images:
|
||||
- location: "https://download.fedoraproject.org/pub/fedora/linux/releases/34/Cloud/x86_64/images/Fedora-Cloud-Base-34-1.2.x86_64.qcow2"
|
||||
arch: "x86_64"
|
||||
mounts:
|
||||
- location: "~"
|
||||
writable: false
|
||||
- location: "/tmp/lima"
|
||||
writable: true
|
||||
ssh:
|
||||
# localPort is changed from 60022 to avoid conflicting with the default.
|
||||
# (TODO: assign localPort automatically)
|
||||
localPort: 60024
|
||||
|
||||
firmware:
|
||||
legacyBIOS: true
|
||||
@@ -0,0 +1,14 @@
|
||||
images:
|
||||
- location: "https://cloud-images.ubuntu.com/hirsute/current/hirsute-server-cloudimg-amd64.img"
|
||||
arch: "x86_64"
|
||||
- location: "https://cloud-images.ubuntu.com/hirsute/current/hirsute-server-cloudimg-arm64.img"
|
||||
arch: "aarch64"
|
||||
mounts:
|
||||
- location: "~"
|
||||
writable: false
|
||||
- location: "/tmp/lima"
|
||||
writable: true
|
||||
ssh:
|
||||
# localPort is changed from 60022 to avoid conflicting with the default.
|
||||
# (TODO: assign localPort automatically)
|
||||
localPort: 60023
|
||||
@@ -0,0 +1,21 @@
|
||||
module github.com/AkihiroSuda/lima
|
||||
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/AkihiroSuda/sshocker v0.1.1-0.20210510144941-56aa3c7472b0
|
||||
github.com/alessio/shellescape v1.4.1
|
||||
github.com/containerd/containerd v1.5.0
|
||||
github.com/cyphar/filepath-securejoin v0.2.2
|
||||
github.com/docker/go-units v0.4.0
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/mattn/go-isatty v0.0.12
|
||||
github.com/norouter/norouter v0.6.3
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/urfave/cli/v2 v2.3.0
|
||||
github.com/yalue/native_endian v1.0.1
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gotest.tools/v3 v3.0.3
|
||||
)
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
# Depends on github.com/vbatts/git-validation"
|
||||
git-validation -run DCO || { echo >&2 'ERROR: DCO sign must be added (git commit -a -s --amend)'; exit 1; }
|
||||
@@ -0,0 +1,122 @@
|
||||
package cidata
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/iso9660util"
|
||||
"github.com/AkihiroSuda/lima/pkg/limayaml"
|
||||
"github.com/AkihiroSuda/lima/pkg/localpathutil"
|
||||
"github.com/AkihiroSuda/lima/pkg/sshutil"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func GenerateISO9660(name string, y *limayaml.LimaYAML) (*iso9660util.ISO9660, error) {
|
||||
if err := limayaml.ValidateRaw(*y); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid, err := strconv.Atoi(u.Uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := TemplateArgs{
|
||||
Name: name,
|
||||
User: u.Username,
|
||||
UID: uid,
|
||||
}
|
||||
for _, f := range sshutil.DefaultPubKeys() {
|
||||
args.SSHPubKeys = append(args.SSHPubKeys, f.Content)
|
||||
}
|
||||
|
||||
for _, f := range y.Mounts {
|
||||
expanded, err := localpathutil.Expand(f.Location)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args.Mounts = append(args.Mounts, expanded)
|
||||
}
|
||||
|
||||
if err := ValidateTemplateArgs(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userData, err := GenerateUserData(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metaData, err := GenerateMetaData(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
guestAgentBinaryPath, err := GuestAgentBinaryPath(y.Arch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iso9660 := &iso9660util.ISO9660{
|
||||
Name: "cidata",
|
||||
FilesFromContent: map[string]string{
|
||||
"user-data": string(userData),
|
||||
"meta-data": string(metaData),
|
||||
},
|
||||
FilesFromHostFilePath: map[string]string{
|
||||
"lima-guestagent": guestAgentBinaryPath,
|
||||
},
|
||||
}
|
||||
|
||||
return iso9660, nil
|
||||
}
|
||||
|
||||
func GuestAgentBinaryPath(arch string) (string, error) {
|
||||
if arch == "" {
|
||||
return "", errors.New("arch must be set")
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
selfSt, err := os.Stat(self)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if selfSt.Mode()&fs.ModeSymlink != 0 {
|
||||
self, err = os.Readlink(self)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// self: /usr/local/bin/limactl
|
||||
selfDir := filepath.Dir(self)
|
||||
selfDirDir := filepath.Dir(selfDir)
|
||||
candidates := []string{
|
||||
// candidate 0:
|
||||
// - self: /Applications/Lima.app/Contents/MacOS/limactl
|
||||
// - agent: /Applications/Lima.app/Contents/MacOS/lima-guestagent.Linux-x86_64
|
||||
filepath.Join(selfDir, "lima-guestagent.Linux-"+arch),
|
||||
// candidate 1:
|
||||
// - self: /usr/local/bin/limactl
|
||||
// - agent: /usr/local/share/lima/lima-guestagent.Linux-x86_64
|
||||
filepath.Join(selfDirDir, "share/lima/lima-guestagent.Linux-"+arch),
|
||||
// TODO: support custom path
|
||||
}
|
||||
for _, f := range candidates {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
return f, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.Errorf("failed to find \"lima-guestagent.Linux-%s\" binary for %q, attempted %v",
|
||||
arch, self, candidates)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
local-hostname: lima-{{.Name}}
|
||||
@@ -0,0 +1,63 @@
|
||||
package cidata
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/templateutil"
|
||||
"github.com/containerd/containerd/identifiers"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed user-data.TEMPLATE
|
||||
userDataTemplate string
|
||||
//go:embed meta-data.TEMPLATE
|
||||
metaDataTemplate string
|
||||
)
|
||||
|
||||
type TemplateArgs struct {
|
||||
Name string // instance name
|
||||
User string // user name
|
||||
UID int
|
||||
SSHPubKeys []string
|
||||
Mounts []string // abs path, accessible by the User
|
||||
}
|
||||
|
||||
func ValidateTemplateArgs(args TemplateArgs) error {
|
||||
if err := identifiers.Validate(args.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := identifiers.Validate(args.User); err != nil {
|
||||
return err
|
||||
}
|
||||
if args.User == "root" {
|
||||
return errors.New("field User must not be \"root\"")
|
||||
}
|
||||
if args.UID == 0 {
|
||||
return errors.New("field UID must not be 0")
|
||||
}
|
||||
if len(args.SSHPubKeys) == 0 {
|
||||
return errors.New("field SSHPubKeys must be set")
|
||||
}
|
||||
for i, f := range args.Mounts {
|
||||
if !filepath.IsAbs(f) {
|
||||
return errors.Errorf("field mounts[%d] must be absolute, got %q", i, f)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateUserData(args TemplateArgs) ([]byte, error) {
|
||||
if err := ValidateTemplateArgs(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return templateutil.Execute(userDataTemplate, args)
|
||||
}
|
||||
|
||||
func GenerateMetaData(args TemplateArgs) ([]byte, error) {
|
||||
if err := ValidateTemplateArgs(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return templateutil.Execute(metaDataTemplate, args)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cidata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestTemplate(t *testing.T) {
|
||||
args := TemplateArgs{
|
||||
Name: "default",
|
||||
User: "foo",
|
||||
UID: 501,
|
||||
SSHPubKeys: []string{
|
||||
"ssh-rsa dummy foo@example.com",
|
||||
},
|
||||
Mounts: []string{
|
||||
"/Users/dummy",
|
||||
"/Users/dummy/lima",
|
||||
},
|
||||
}
|
||||
userData, err := GenerateUserData(args)
|
||||
assert.NilError(t, err)
|
||||
t.Log(string(userData))
|
||||
|
||||
metaData, err := GenerateMetaData(args)
|
||||
assert.NilError(t, err)
|
||||
t.Log(string(metaData))
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#cloud-config
|
||||
# vim:syntax=yaml
|
||||
|
||||
growpart:
|
||||
mode: auto
|
||||
devices: ['/']
|
||||
|
||||
users:
|
||||
- name: "{{.User}}"
|
||||
uid: "{{.UID}}"
|
||||
homedir: "/home/{{.User}}.linux"
|
||||
shell: /bin/bash
|
||||
sudo: ALL=(ALL) NOPASSWD:ALL
|
||||
lock_passwd: true
|
||||
ssh-authorized-keys:
|
||||
{{range $val := .SSHPubKeys}}
|
||||
- {{$val}}
|
||||
{{end}}
|
||||
|
||||
write_files:
|
||||
- content: |
|
||||
#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
|
||||
# Enable rootless containers
|
||||
if [ ! -e "/etc/systemd/system/user@.service.d/lima.conf" ]; then
|
||||
mkdir -p "/etc/systemd/system/user@.service.d"
|
||||
cat >"/etc/systemd/system/user@.service.d/lima.conf" <<EOF
|
||||
[Service]
|
||||
Delegate=yes
|
||||
EOF
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
|
||||
sysctl_conf="/etc/sysctl.d/99-lima.conf"
|
||||
if [ ! -e "${sysctl_conf}" ]; then
|
||||
if [ -e "/proc/sys/kernel/unprivileged_userns_clone" ]; then
|
||||
echo "kernel.unprivileged_userns_clone=1" >> "${sysctl_conf}"
|
||||
fi
|
||||
echo "net.ipv4.ping_group_range = 0 2147483647" >> "${sysctl_conf}"
|
||||
echo "net.ipv4.ip_unprivileged_port_start=0" >> "${sysctl_conf}"
|
||||
sysctl --system
|
||||
fi
|
||||
|
||||
# Do additional setup for the user
|
||||
for f in /etc/subuid /etc/subgid; do
|
||||
grep -qw "{{.User}}" $f || echo "{{.User}}:100000:65536" >> $f
|
||||
done
|
||||
loginctl enable-linger "{{.User}}"
|
||||
|
||||
# Create mount points
|
||||
{{range $val := .Mounts}}
|
||||
mkdir -p "{{$val}}"
|
||||
chown "{{$.User}}" "{{$val}}" || true
|
||||
{{end}}
|
||||
|
||||
# Install or update the guestagent binary
|
||||
mkdir -p -m 600 /mnt/lima-cidata
|
||||
mount -t iso9660 -o ro /dev/disk/by-label/cidata /mnt/lima-cidata
|
||||
install -m 755 /mnt/lima-cidata/lima-guestagent /usr/local/bin/lima-guestagent
|
||||
umount /mnt/lima-cidata
|
||||
|
||||
# Launch the guestagent service
|
||||
until [ -e "/run/user/{{.UID}}/systemd/private" ]; do sleep 3; done
|
||||
sudo -iu "{{.User}}" "XDG_RUNTIME_DIR=/run/user/{{.UID}}" lima-guestagent install-systemd
|
||||
owner: root:root
|
||||
# We do not use per-once.
|
||||
path: /var/lib/cloud/scripts/per-boot/00-base.boot.sh
|
||||
permissions: '0755'
|
||||
- content: |
|
||||
#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
# Install minimum dependencies
|
||||
if command -v apt-get 2>&1 >/dev/null; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y sshfs uidmap
|
||||
elif command -v dnf 2>&1 >/dev/null; then
|
||||
dnf install -y fuse-sshfs shadow-utils
|
||||
fi
|
||||
owner: root:root
|
||||
path: /var/lib/cloud/scripts/per-boot/10-install-packages.boot.sh
|
||||
permissions: '0755'
|
||||
- content: |
|
||||
#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
modprobe tap || true
|
||||
if [ ! -e "/home/{{.User}}.linux/.config/containerd/config.toml" ]; then
|
||||
mkdir -p "/home/{{.User}}.linux/.config/containerd"
|
||||
cat >"/home/{{.User}}.linux/.config/containerd/config.toml" <<EOF
|
||||
[proxy_plugins]
|
||||
[proxy_plugins."stargz"]
|
||||
type = "snapshot"
|
||||
address = "/run/user/{{.UID}}/containerd-stargz-grpc/containerd-stargz-grpc.sock"
|
||||
EOF
|
||||
chown -R "{{.User}}" "/home/{{.User}}.linux/.config"
|
||||
fi
|
||||
if [ ! -x /usr/local/bin/nerdctl -a "$(uname -m)" = "x86_64" ]; then
|
||||
curl -fsSL https://github.com/containerd/nerdctl/releases/download/v0.8.1/nerdctl-full-0.8.1-linux-amd64.tar.gz | tar Cxz /usr/local
|
||||
until [ -e "/run/user/{{.UID}}/systemd/private" ]; do sleep 3; done
|
||||
sudo -iu "{{.User}}" "XDG_RUNTIME_DIR=/run/user/{{.UID}}" containerd-rootless-setuptool.sh install
|
||||
sudo -iu "{{.User}}" "XDG_RUNTIME_DIR=/run/user/{{.UID}}" containerd-rootless-setuptool.sh install-buildkit
|
||||
sudo -iu "{{.User}}" "XDG_RUNTIME_DIR=/run/user/{{.UID}}" containerd-rootless-setuptool.sh install-stargz
|
||||
else
|
||||
echo "TODO: install containerd for aarch64 (https://github.com/containerd/nerdctl/issues/191)"
|
||||
fi
|
||||
owner: root:root
|
||||
path: /var/lib/cloud/scripts/per-boot/20-install-containerd.boot.sh
|
||||
permissions: '0755'
|
||||
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrorJSON is returned with "application/json" content type and non-2XX status code
|
||||
type ErrorJSON struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type IPPort struct {
|
||||
IP net.IP `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
func (x *IPPort) String() string {
|
||||
return net.JoinHostPort(x.IP.String(), strconv.Itoa(x.Port))
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
// LocalPorts contain 127.0.0.1 and 0.0.0.0.
|
||||
// LocalPorts do NOT contain addresses such as 127.0.0.53 and 192.168.5.15.
|
||||
//
|
||||
// In future, LocalPorts will contain IPv6 addresses (::1 and ::) as well.
|
||||
LocalPorts []IPPort `json:"localPorts"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Time time.Time `json:"time,omitempty"`
|
||||
// The first event contains the full ports as LocalPortsAdded
|
||||
LocalPortsAdded []IPPort `json:"localPortsAdded,omitempty"`
|
||||
LocalPortsRemoved []IPPort `json:"localPortsRemoved,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package client
|
||||
|
||||
// Forked from https://github.com/rootless-containers/rootlesskit/blob/v0.14.2/pkg/api/client/client.go
|
||||
// Apache License 2.0
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GuestAgentClient interface {
|
||||
HTTPClient() *http.Client
|
||||
Info(context.Context) (*api.Info, error)
|
||||
Events(context.Context, func(api.Event)) error
|
||||
}
|
||||
|
||||
// NewGuestAgentClient creates a client.
|
||||
// socketPath is a path to the UNIX socket, without unix:// prefix.
|
||||
func NewGuestAgentClient(socketPath string) (GuestAgentClient, error) {
|
||||
if _, err := os.Stat(socketPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hc := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", socketPath)
|
||||
},
|
||||
},
|
||||
}
|
||||
return NewGuestAgentClientWithHTTPClient(hc), nil
|
||||
}
|
||||
|
||||
func NewGuestAgentClientWithHTTPClient(hc *http.Client) GuestAgentClient {
|
||||
return &client{
|
||||
Client: hc,
|
||||
version: "v1",
|
||||
dummyHost: "lima-guestagent",
|
||||
}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
*http.Client
|
||||
// version is always "v1"
|
||||
// TODO(AkihiroSuda): negotiate the version
|
||||
version string
|
||||
dummyHost string
|
||||
}
|
||||
|
||||
func (c *client) HTTPClient() *http.Client {
|
||||
return c.Client
|
||||
}
|
||||
|
||||
func (c *client) Info(ctx context.Context) (*api.Info, error) {
|
||||
u := fmt.Sprintf("http://%s/%s/info", c.dummyHost, c.version)
|
||||
req, err := http.NewRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := c.HTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := successful(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var info api.Info
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if err := dec.Decode(&info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (c *client) Events(ctx context.Context, onEvent func(api.Event)) error {
|
||||
u := fmt.Sprintf("http://%s/%s/events", c.dummyHost, c.version)
|
||||
req, err := http.NewRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := c.HTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := successful(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
var ev api.Event
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
for {
|
||||
if err := dec.Decode(&ev); err != nil {
|
||||
return err
|
||||
}
|
||||
onEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func readAtMost(r io.Reader, maxBytes int) ([]byte, error) {
|
||||
lr := &io.LimitedReader{
|
||||
R: r,
|
||||
N: int64(maxBytes),
|
||||
}
|
||||
b, err := ioutil.ReadAll(lr)
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
if lr.N == 0 {
|
||||
return b, errors.Errorf("expected at most %d bytes, got more", maxBytes)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// HTTPStatusErrorBodyMaxLength specifies the maximum length of HTTPStatusError.Body
|
||||
const HTTPStatusErrorBodyMaxLength = 64 * 1024
|
||||
|
||||
// HTTPStatusError is created from non-2XX HTTP response
|
||||
type HTTPStatusError struct {
|
||||
// StatusCode is non-2XX status code
|
||||
StatusCode int
|
||||
// Body is at most HTTPStatusErrorBodyMaxLength
|
||||
Body string
|
||||
}
|
||||
|
||||
// Error implements error.
|
||||
// If e.Body is a marshalled string of api.ErrorJSON, Error returns ErrorJSON.Message .
|
||||
// Otherwise Error returns a human-readable string that contains e.StatusCode and e.Body.
|
||||
func (e *HTTPStatusError) Error() string {
|
||||
if e.Body != "" && len(e.Body) < HTTPStatusErrorBodyMaxLength {
|
||||
var ej api.ErrorJSON
|
||||
if json.Unmarshal([]byte(e.Body), &ej) == nil {
|
||||
return ej.Message
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("unexpected HTTP status %s, body=%q", http.StatusText(e.StatusCode), e.Body)
|
||||
}
|
||||
|
||||
func successful(resp *http.Response) error {
|
||||
if resp == nil {
|
||||
return errors.New("nil response")
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := readAtMost(resp.Body, HTTPStatusErrorBodyMaxLength)
|
||||
return &HTTPStatusError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(b),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent"
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
Agent guestagent.Agent
|
||||
}
|
||||
|
||||
func (b *Backend) onError(w http.ResponseWriter, r *http.Request, err error, ec int) {
|
||||
w.WriteHeader(ec)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// it is safe to return the err to the client, because the client is reliable
|
||||
e := api.ErrorJSON{
|
||||
Message: err.Error(),
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(e)
|
||||
}
|
||||
|
||||
// GetInfo is the handler for GET /v{N}/info
|
||||
func (b *Backend) GetInfo(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
info, err := b.Agent.Info(ctx)
|
||||
if err != nil {
|
||||
b.onError(w, r, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
m, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
b.onError(w, r, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(m)
|
||||
}
|
||||
|
||||
// GetEvents is the handler for GET /v{N}/events.
|
||||
func (b *Backend) GetEvents(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
panic("http.ResponseWriter has to implement http.Flusher")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
ch := make(chan api.Event)
|
||||
go b.Agent.Events(ctx, ch)
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
for ev := range ch {
|
||||
if err := enc.Encode(ev); err != nil {
|
||||
logrus.Warn(err)
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func AddRoutes(r *mux.Router, b *Backend) {
|
||||
v1 := r.PathPrefix("/v1").Subrouter()
|
||||
v1.Path("/info").Methods("GET").HandlerFunc(b.GetInfo)
|
||||
v1.Path("/events").Methods("GET").HandlerFunc(b.GetEvents)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package guestagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
|
||||
)
|
||||
|
||||
type Agent interface {
|
||||
Info(ctx context.Context) (*api.Info, error)
|
||||
Events(ctx context.Context, ch chan api.Event)
|
||||
LocalPorts(ctx context.Context) ([]api.IPPort, error)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package guestagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent/procnettcp"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/yalue/native_endian"
|
||||
)
|
||||
|
||||
func New(newTicker func() (<-chan time.Time, func())) Agent {
|
||||
return &agent{
|
||||
newTicker: newTicker,
|
||||
}
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
// Ticker is like time.Ticker.
|
||||
// We can't use inotify for /proc/net/tcp, so we need this ticker to
|
||||
// reload /proc/net/tcp.
|
||||
newTicker func() (<-chan time.Time, func())
|
||||
}
|
||||
|
||||
type eventState struct {
|
||||
ports []api.IPPort
|
||||
}
|
||||
|
||||
func comparePorts(old, neww []api.IPPort) (added, removed []api.IPPort) {
|
||||
mRaw := make(map[string]api.IPPort, len(old))
|
||||
mStillExist := make(map[string]bool, len(old))
|
||||
|
||||
for _, f := range old {
|
||||
k := f.String()
|
||||
mRaw[k] = f
|
||||
mStillExist[k] = false
|
||||
}
|
||||
for _, f := range neww {
|
||||
k := f.String()
|
||||
if _, ok := mRaw[k]; !ok {
|
||||
added = append(added, f)
|
||||
}
|
||||
mStillExist[k] = true
|
||||
}
|
||||
|
||||
for k, stillExist := range mStillExist {
|
||||
if !stillExist {
|
||||
if x, ok := mRaw[k]; ok {
|
||||
removed = append(removed, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (a *agent) collectEvent(ctx context.Context, st eventState) (api.Event, eventState) {
|
||||
var (
|
||||
ev api.Event
|
||||
err error
|
||||
)
|
||||
newSt := st
|
||||
newSt.ports, err = a.LocalPorts(ctx)
|
||||
if err != nil {
|
||||
ev.Errors = append(ev.Errors, err.Error())
|
||||
ev.Time = time.Now()
|
||||
return ev, newSt
|
||||
}
|
||||
ev.LocalPortsAdded, ev.LocalPortsRemoved = comparePorts(st.ports, newSt.ports)
|
||||
ev.Time = time.Now()
|
||||
return ev, newSt
|
||||
}
|
||||
|
||||
func isEventEmpty(ev api.Event) bool {
|
||||
var empty api.Event
|
||||
// ignore ev.Time
|
||||
copied := ev
|
||||
copied.Time = time.Time{}
|
||||
return reflect.DeepEqual(empty, copied)
|
||||
}
|
||||
|
||||
func (a *agent) Events(ctx context.Context, ch chan api.Event) {
|
||||
defer close(ch)
|
||||
tickerCh, tickerClose := a.newTicker()
|
||||
defer tickerClose()
|
||||
var st eventState
|
||||
for {
|
||||
var ev api.Event
|
||||
ev, st = a.collectEvent(ctx, st)
|
||||
if !isEventEmpty(ev) {
|
||||
ch <- ev
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case _, ok := <-tickerCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
logrus.Debug("tick!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) LocalPorts(ctx context.Context) ([]api.IPPort, error) {
|
||||
if native_endian.NativeEndian() == binary.BigEndian {
|
||||
return nil, errors.New("big endian architecture is unsupported, because I don't know how /proc/net/tcp looks like on big endian hosts")
|
||||
}
|
||||
var res []api.IPPort
|
||||
tcpParsed, err := procnettcp.ParseFiles()
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
ipv4Loopback1 := net.ParseIP("127.0.0.1")
|
||||
for _, f := range tcpParsed {
|
||||
switch f.Kind {
|
||||
case procnettcp.TCP, procnettcp.TCP6:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
isListen := f.State == procnettcp.TCPListen
|
||||
// We do NOT use net.IP.IsLoopback() here, because we want to exclude addresses like 127.0.0.53 here.
|
||||
isLocal := f.IP.Equal(net.IPv4zero) || f.IP.Equal(net.IPv6zero) ||
|
||||
f.IP.Equal(ipv4Loopback1) || f.IP.Equal(net.IPv6loopback)
|
||||
if isListen && isLocal {
|
||||
res = append(res,
|
||||
api.IPPort{
|
||||
IP: f.IP,
|
||||
Port: int(f.Port),
|
||||
})
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (a *agent) Info(ctx context.Context) (*api.Info, error) {
|
||||
var (
|
||||
info api.Info
|
||||
err error
|
||||
)
|
||||
info.LocalPorts, err = a.LocalPorts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package procnettcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Kind = string
|
||||
|
||||
const (
|
||||
TCP Kind = "tcp"
|
||||
TCP6 Kind = "tcp6"
|
||||
// TODO: "udp", "udp6", "udplite", "udplite6"
|
||||
)
|
||||
|
||||
type State = int
|
||||
|
||||
const (
|
||||
TCPEstablished State = 0x1
|
||||
TCPListen State = 0xA
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Kind Kind `json:"kind"`
|
||||
IP net.IP `json:"ip"`
|
||||
Port uint16 `json:"port"`
|
||||
State State `json:"state"`
|
||||
}
|
||||
|
||||
func Parse(r io.Reader, kind Kind) ([]Entry, error) {
|
||||
switch kind {
|
||||
case TCP, TCP6:
|
||||
default:
|
||||
return nil, errors.Errorf("unexpected kind %q", kind)
|
||||
}
|
||||
|
||||
var entries []Entry
|
||||
sc := bufio.NewScanner(r)
|
||||
|
||||
// As of kernel 5.11, ["local_address"] = 1
|
||||
fieldNames := make(map[string]int)
|
||||
for i := 0; sc.Scan(); i++ {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
switch i {
|
||||
case 0:
|
||||
for j := 0; j < len(fields); j++ {
|
||||
fieldNames[fields[j]] = j
|
||||
}
|
||||
if _, ok := fieldNames["local_address"]; !ok {
|
||||
return nil, errors.Errorf("field \"local_address\" not found")
|
||||
}
|
||||
if _, ok := fieldNames["st"]; !ok {
|
||||
return nil, errors.Errorf("field \"st\" not found")
|
||||
}
|
||||
|
||||
default:
|
||||
// localAddress is like "0100007F:053A"
|
||||
localAddress := fields[fieldNames["local_address"]]
|
||||
ip, port, err := ParseAddress(localAddress)
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
|
||||
stStr := fields[fieldNames["st"]]
|
||||
st, err := strconv.ParseUint(stStr, 16, 8)
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
|
||||
ent := Entry{
|
||||
Kind: kind,
|
||||
IP: ip,
|
||||
Port: port,
|
||||
State: int(st),
|
||||
}
|
||||
entries = append(entries, ent)
|
||||
}
|
||||
}
|
||||
|
||||
if err := sc.Err(); err != nil {
|
||||
return entries, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// ParseAddress parses a string, e.g.,
|
||||
// "0100007F:0050" (127.0.0.1:80)
|
||||
// "000080FE00000000FF57A6705DC771FE:0050" ([fe80::70a6:57ff:fe71:c75d]:80)
|
||||
// "00000000000000000000000000000000:0050" (0.0.0.0:80)
|
||||
//
|
||||
// See https://serverfault.com/questions/592574/why-does-proc-net-tcp6-represents-1-as-1000
|
||||
//
|
||||
// ParseAddress is expected to be used for /proc/net/{tcp,tcp6} entries on
|
||||
// little endian machines.
|
||||
// Not sure how those entries look like on big endian machines.
|
||||
func ParseAddress(s string) (net.IP, uint16, error) {
|
||||
split := strings.SplitN(s, ":", 2)
|
||||
if len(split) != 2 {
|
||||
return nil, 0, errors.Errorf("unparsable address %q", s)
|
||||
}
|
||||
switch l := len(split[0]); l {
|
||||
case 8, 32:
|
||||
default:
|
||||
return nil, 0, errors.Errorf("unparsable address %q, expected length of %q to be 8 or 32, got %d",
|
||||
s, len(split[0]), l)
|
||||
}
|
||||
|
||||
ipBytes := make([]byte, len(split[0])/2) // 4 bytes (8 chars) or 16 bytes (32 chars)
|
||||
for i := 0; i < len(split[0])/8; i++ {
|
||||
quartet := split[0][8*i : 8*(i+1)]
|
||||
quartetLE, err := hex.DecodeString(quartet) // surprisingly little endian, per 4 bytes
|
||||
if err != nil {
|
||||
return nil, 0, errors.Wrapf(err, "unparsable address %q: unparsable quartet %q", s, quartet)
|
||||
}
|
||||
for j := 0; j < len(quartetLE); j++ {
|
||||
ipBytes[4*i+len(quartetLE)-1-j] = quartetLE[j]
|
||||
}
|
||||
}
|
||||
ip := net.IP(ipBytes)
|
||||
|
||||
port64, err := strconv.ParseUint(split[1], 16, 16)
|
||||
if err != nil {
|
||||
return nil, 0, errors.Errorf("unparsable address %q: unparsable port %q", s, split[1])
|
||||
}
|
||||
port := uint16(port64)
|
||||
|
||||
return ip, port, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package procnettcp
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// ParseFiles parses /proc/net/{tcp, tcp6}
|
||||
func ParseFiles() ([]Entry, error) {
|
||||
var res []Entry
|
||||
files := map[string]Kind{
|
||||
"/proc/net/tcp": TCP,
|
||||
"/proc/net/tcp6": TCP6,
|
||||
}
|
||||
for file, kind := range files {
|
||||
r, err := os.Open(file)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
defer r.Close()
|
||||
parsed, err := Parse(r, kind)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res = append(res, parsed...)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package procnettcp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestParseTCP(t *testing.T) {
|
||||
procNetTCP := ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
0: 0100007F:8AEF 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 28152 1 0000000000000000 100 0 0 10 0
|
||||
1: 0103000A:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 31474 1 0000000000000000 100 0 0 10 5
|
||||
2: 3500007F:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 102 0 30955 1 0000000000000000 100 0 0 10 0
|
||||
3: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 32910 1 0000000000000000 100 0 0 10 0
|
||||
4: 0100007F:053A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 31430 1 0000000000000000 100 0 0 10 0
|
||||
5: 0B3CA8C0:0016 690AA8C0:F705 01 00000000:00000000 02:00028D8B 00000000 0 0 32989 4 0000000000000000 20 4 31 10 19
|
||||
`
|
||||
entries, err := Parse(strings.NewReader(procNetTCP), TCP)
|
||||
assert.NilError(t, err)
|
||||
t.Log(entries)
|
||||
|
||||
assert.Check(t, net.ParseIP("127.0.0.1").Equal(entries[0].IP))
|
||||
assert.Equal(t, uint16(35567), entries[0].Port)
|
||||
assert.Equal(t, TCPListen, entries[0].State)
|
||||
|
||||
assert.Check(t, net.ParseIP("192.168.60.11").Equal(entries[5].IP))
|
||||
assert.Equal(t, uint16(22), entries[5].Port)
|
||||
assert.Equal(t, TCPEstablished, entries[5].State)
|
||||
}
|
||||
|
||||
func TestParseTCP6(t *testing.T) {
|
||||
procNetTCP := ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
0: 000080FE00000000FF57A6705DC771FE:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 850222 1 0000000000000000 100 0 0 10 0`
|
||||
entries, err := Parse(strings.NewReader(procNetTCP), TCP6)
|
||||
assert.NilError(t, err)
|
||||
t.Log(entries)
|
||||
|
||||
assert.Check(t, net.ParseIP("fe80::70a6:57ff:fe71:c75d").Equal(entries[0].IP))
|
||||
assert.Equal(t, uint16(80), entries[0].Port)
|
||||
assert.Equal(t, TCPListen, entries[0].State)
|
||||
}
|
||||
|
||||
func TestParseTCP6Zero(t *testing.T) {
|
||||
procNetTCP := ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
0: 00000000000000000000000000000000:0016 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 33825 1 0000000000000000 100 0 0 10 0
|
||||
1: 00000000000000000000000000000000:006F 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 26772 1 0000000000000000 100 0 0 10 0
|
||||
2: 00000000000000000000000000000000:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1210901 1 0000000000000000 100 0 0 10 0
|
||||
`
|
||||
entries, err := Parse(strings.NewReader(procNetTCP), TCP6)
|
||||
assert.NilError(t, err)
|
||||
t.Log(entries)
|
||||
|
||||
assert.Check(t, net.IPv6zero.Equal(entries[0].IP))
|
||||
assert.Equal(t, uint16(22), entries[0].Port)
|
||||
assert.Equal(t, TCPListen, entries[0].State)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package hostagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent/api/client"
|
||||
"github.com/AkihiroSuda/lima/pkg/limayaml"
|
||||
"github.com/AkihiroSuda/lima/pkg/sshutil"
|
||||
"github.com/AkihiroSuda/sshocker/pkg/ssh"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type HostAgent struct {
|
||||
y *limayaml.LimaYAML
|
||||
instDir string
|
||||
sshConfig *ssh.SSHConfig
|
||||
portForwarder *portForwarder
|
||||
onClose []func() error // LIFO
|
||||
}
|
||||
|
||||
func New(y *limayaml.LimaYAML, instDir string) (*HostAgent, error) {
|
||||
sshArgs, err := sshutil.SSHArgs(instDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sshConfig := &ssh.SSHConfig{
|
||||
AdditionalArgs: sshArgs,
|
||||
}
|
||||
a := &HostAgent{
|
||||
y: y,
|
||||
instDir: instDir,
|
||||
sshConfig: sshConfig,
|
||||
portForwarder: newPortForwarder(sshConfig, y.SSH.LocalPort),
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *HostAgent) Run(ctx context.Context) error {
|
||||
a.onClose = append(a.onClose, func() error {
|
||||
logrus.Debugf("shutting down the SSH master")
|
||||
if exitMasterErr := ssh.ExitMaster("127.0.0.1", a.y.SSH.LocalPort, a.sshConfig); exitMasterErr != nil {
|
||||
logrus.WithError(exitMasterErr).Warn("failed to exit SSH master")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
var mErr error
|
||||
if err := a.waitForRequirements(ctx, "essential", essentialRequirements); err != nil {
|
||||
mErr = multierror.Append(mErr, err)
|
||||
}
|
||||
mounts, err := a.setupMounts(ctx)
|
||||
if err != nil {
|
||||
mErr = multierror.Append(mErr, err)
|
||||
}
|
||||
a.onClose = append(a.onClose, func() error {
|
||||
var unmountMErr error
|
||||
for _, m := range mounts {
|
||||
if unmountErr := m.close(); unmountErr != nil {
|
||||
unmountMErr = multierror.Append(unmountMErr, unmountErr)
|
||||
}
|
||||
}
|
||||
return unmountMErr
|
||||
})
|
||||
go a.watchGuestAgentEvents(ctx)
|
||||
if err := a.waitForRequirements(ctx, "optional", optionalRequirements); err != nil {
|
||||
mErr = multierror.Append(mErr, err)
|
||||
}
|
||||
return mErr
|
||||
}
|
||||
|
||||
func (a *HostAgent) Close() error {
|
||||
logrus.Infof("Shutting down the host agent")
|
||||
var mErr error
|
||||
for i := len(a.onClose) - 1; i >= 0; i-- {
|
||||
f := a.onClose[i]
|
||||
if err := f(); err != nil {
|
||||
mErr = multierror.Append(mErr, err)
|
||||
}
|
||||
}
|
||||
return mErr
|
||||
}
|
||||
|
||||
func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
|
||||
// TODO: use vSock (when QEMU for macOS gets support for vSock)
|
||||
|
||||
localUnix := filepath.Join(a.instDir, "ga.sock")
|
||||
// guest should have same UID as the host (specified in cidata)
|
||||
remoteUnix := fmt.Sprintf("/run/user/%d/lima-guestagent.sock", os.Getuid())
|
||||
|
||||
for {
|
||||
if !isGuestAgentSocketAccessible(ctx, localUnix) {
|
||||
if err := os.RemoveAll(localUnix); err != nil {
|
||||
logrus.WithError(err).Warnf("failed to clean up %q (host) before setting up forwarding", localUnix)
|
||||
}
|
||||
logrus.Infof("Forwarding %q (guest) to %q (host)", remoteUnix, localUnix)
|
||||
if err := forwardSSH(ctx, a.sshConfig, a.y.SSH.LocalPort, localUnix, remoteUnix, false); err != nil {
|
||||
logrus.WithError(err).Warnf("failed to setting up forward from %q (guest) to %q (host)", remoteUnix, localUnix)
|
||||
}
|
||||
}
|
||||
if err := a.processGuestAgentEvents(ctx, localUnix); err != nil {
|
||||
logrus.WithError(err).Warn("connection to the guest agent was closed unexpectedly")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logrus.Infof("Stopping forwarding %q to %q", remoteUnix, localUnix)
|
||||
verbCancel := true
|
||||
if err := forwardSSH(ctx, a.sshConfig, a.y.SSH.LocalPort, localUnix, remoteUnix, verbCancel); err != nil {
|
||||
logrus.WithError(err).Warnf("failed to stop forwarding %q (remote) to %q (local)", remoteUnix, localUnix)
|
||||
}
|
||||
if err := os.RemoveAll(localUnix); err != nil {
|
||||
logrus.WithError(err).Warnf("failed to clean up %q (host) after stopping forwarding", localUnix)
|
||||
}
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isGuestAgentSocketAccessible(ctx context.Context, localUnix string) bool {
|
||||
client, err := client.NewGuestAgentClient(localUnix)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = client.Info(ctx)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (a *HostAgent) processGuestAgentEvents(ctx context.Context, localUnix string) error {
|
||||
client, err := client.NewGuestAgentClient(localUnix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := client.Info(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("guest agent info: %+v", info)
|
||||
|
||||
onEvent := func(ev api.Event) {
|
||||
logrus.Debugf("guest agent event: %+v", ev)
|
||||
for _, f := range ev.Errors {
|
||||
logrus.Warnf("received error from the guest: %q", f)
|
||||
}
|
||||
a.portForwarder.OnEvent(ctx, ev)
|
||||
}
|
||||
|
||||
if err := client.Events(ctx, onEvent); err != nil {
|
||||
return err
|
||||
}
|
||||
return io.EOF
|
||||
}
|
||||
|
||||
func forwardSSH(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, remote string, cancel bool) error {
|
||||
args := sshConfig.Args()
|
||||
verb := "forward"
|
||||
if cancel {
|
||||
verb = "cancel"
|
||||
}
|
||||
args = append(args,
|
||||
"-T",
|
||||
"-O", verb,
|
||||
"-L", local+":"+remote,
|
||||
"-N",
|
||||
"-f",
|
||||
"-p", strconv.Itoa(port),
|
||||
"127.0.0.1",
|
||||
"--",
|
||||
)
|
||||
cmd := exec.CommandContext(ctx, sshConfig.Binary(), args...)
|
||||
if out, err := cmd.Output(); err != nil {
|
||||
return errors.Wrapf(err, "failed to run %v: %q", cmd.Args, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package hostagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/limayaml"
|
||||
"github.com/AkihiroSuda/lima/pkg/localpathutil"
|
||||
"github.com/AkihiroSuda/sshocker/pkg/reversesshfs"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type mount struct {
|
||||
close func() error
|
||||
}
|
||||
|
||||
func (a *HostAgent) setupMounts(ctx context.Context) ([]*mount, error) {
|
||||
var (
|
||||
res []*mount
|
||||
mErr error
|
||||
)
|
||||
for _, f := range a.y.Mounts {
|
||||
m, err := a.setupMount(ctx, f)
|
||||
if err != nil {
|
||||
mErr = multierror.Append(mErr, err)
|
||||
continue
|
||||
}
|
||||
res = append(res, m)
|
||||
}
|
||||
return res, mErr
|
||||
}
|
||||
|
||||
func (a *HostAgent) setupMount(ctx context.Context, m limayaml.Mount) (*mount, error) {
|
||||
expanded, err := localpathutil.Expand(m.Location)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(expanded, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logrus.Infof("Mounting %q", expanded)
|
||||
rsf := &reversesshfs.ReverseSSHFS{
|
||||
SSHConfig: a.sshConfig,
|
||||
LocalPath: expanded,
|
||||
Host: "127.0.0.1",
|
||||
Port: a.y.SSH.LocalPort,
|
||||
RemotePath: expanded,
|
||||
Readonly: !m.Writable,
|
||||
}
|
||||
if err := rsf.Prepare(); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to prepare reverse sshfs for %q", expanded)
|
||||
}
|
||||
if err := rsf.Start(); err != nil {
|
||||
logrus.WithError(err).Warnf("failed to mount reverse sshfs for %q, retrying with `-o nonempty`", expanded)
|
||||
// NOTE: nonempty is not supported for libfuse3: https://github.com/canonical/multipass/issues/1381
|
||||
rsf.SSHFSAdditionalArgs = []string{"-o", "nonempty"}
|
||||
if err := rsf.Start(); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to mount reverse sshfs for %q", expanded)
|
||||
}
|
||||
}
|
||||
|
||||
res := &mount{
|
||||
close: func() error {
|
||||
logrus.Infof("Unmounting %q", expanded)
|
||||
if closeErr := rsf.Close(); closeErr != nil {
|
||||
return errors.Wrapf(err, "failed to unmount reverse sshfs for %q", expanded)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package hostagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
|
||||
"github.com/AkihiroSuda/sshocker/pkg/ssh"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type portForwarder struct {
|
||||
sshConfig *ssh.SSHConfig
|
||||
sshHostPort int
|
||||
tcp map[int]struct{} // key: int (NOTE: this might be inconsistent with the actual status of SSH master)
|
||||
}
|
||||
|
||||
const sshGuestPort = 22
|
||||
|
||||
func newPortForwarder(sshConfig *ssh.SSHConfig, sshHostPort int) *portForwarder {
|
||||
return &portForwarder{
|
||||
sshConfig: sshConfig,
|
||||
sshHostPort: sshHostPort,
|
||||
tcp: make(map[int]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (pf *portForwarder) OnEvent(ctx context.Context, ev api.Event) {
|
||||
ignore := func(x api.IPPort) bool {
|
||||
switch x.Port {
|
||||
case sshGuestPort, pf.sshHostPort:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, f := range ev.LocalPortsRemoved {
|
||||
if ignore(f) {
|
||||
continue
|
||||
}
|
||||
// pf.tcp might be inconsistent with the actual state of the SSH master,
|
||||
// so we always attempt to cancel forwarding, even when f.Port is not tracked in pf.tcp.
|
||||
logrus.Infof("Stopping forwarding TCP port %d", f.Port)
|
||||
verbCancel := true
|
||||
if err := forwardSSH(ctx, pf.sshConfig, pf.sshHostPort, "127.0.0.1:"+strconv.Itoa(f.Port), "127.0.0.1:"+strconv.Itoa(f.Port), verbCancel); err != nil {
|
||||
if _, ok := pf.tcp[f.Port]; ok {
|
||||
logrus.WithError(err).Warnf("failed to stop forwarding TCP port %d", f.Port)
|
||||
} else {
|
||||
logrus.WithError(err).Debugf("failed to stop forwarding TCP port %d (negligible)", f.Port)
|
||||
}
|
||||
}
|
||||
delete(pf.tcp, f.Port)
|
||||
}
|
||||
for _, f := range ev.LocalPortsAdded {
|
||||
if ignore(f) {
|
||||
continue
|
||||
}
|
||||
logrus.Infof("Forwarding TCP port %d", f.Port)
|
||||
if err := forwardSSH(ctx, pf.sshConfig, pf.sshHostPort, "127.0.0.1:"+strconv.Itoa(f.Port), "127.0.0.1:"+strconv.Itoa(f.Port), false); err != nil {
|
||||
logrus.WithError(err).Warnf("failed to setting up forward TCP port %d (negligible if already forwarded)", f.Port)
|
||||
} else {
|
||||
pf.tcp[f.Port] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package hostagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/AkihiroSuda/sshocker/pkg/ssh"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (a *HostAgent) waitForRequirements(ctx context.Context, label string, requirements []requirement) error {
|
||||
const (
|
||||
retries = 60
|
||||
sleepDuration = 10 * time.Second
|
||||
)
|
||||
var mErr error
|
||||
|
||||
for i, req := range requirements {
|
||||
retryLoop:
|
||||
for j := 0; j < retries; j++ {
|
||||
logrus.Infof("Waiting for the %s requirement %d of %d: %q", label, i+1, len(requirements), req.description)
|
||||
err := a.waitForRequirement(ctx, req)
|
||||
if err == nil {
|
||||
logrus.Infof("The %s requirement %d of %d is satisfied", label, i+1, len(requirements))
|
||||
break retryLoop
|
||||
}
|
||||
if j == retries-1 {
|
||||
mErr = multierror.Append(mErr,
|
||||
errors.Wrapf(err, "failed to satisfy the %s requirement %d of %d %q: %s",
|
||||
label, i+1, len(requirements), req.description, req.debugHint))
|
||||
break retryLoop
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
return mErr
|
||||
}
|
||||
|
||||
func (a *HostAgent) waitForRequirement(ctx context.Context, r requirement) error {
|
||||
logrus.Debugf("executing script %q", r.description)
|
||||
stdout, stderr, err := ssh.ExecuteScript("127.0.0.1", a.y.SSH.LocalPort, a.sshConfig, r.script, r.description)
|
||||
logrus.Debugf("stdout=%q, stderr=%q, err=%v", stdout, stderr, err)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "stdout=%q, stderr=%q", stdout, stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type requirement struct {
|
||||
description string
|
||||
script string
|
||||
debugHint string
|
||||
}
|
||||
|
||||
var essentialRequirements = []requirement{
|
||||
{
|
||||
description: "ssh",
|
||||
script: `#!/bin/bash
|
||||
true
|
||||
`,
|
||||
debugHint: `Failed to SSH into the guest.
|
||||
Make sure that the YAML field "ssh.localPort" is not used by other processes on the host.
|
||||
If any private key under ~/.ssh is protected with a passphrase, you need to have ssh-agent to be running.
|
||||
`,
|
||||
},
|
||||
{
|
||||
description: "sshfs binary to be installed",
|
||||
script: `#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
if ! timeout 30s bash -c "until command -v sshfs; do sleep 3; done"; then
|
||||
echo >&2 "sshfs is not installed yet"
|
||||
exit 1
|
||||
fi
|
||||
`,
|
||||
debugHint: `The sshfs binary was not installed in the guest.
|
||||
Make sure that you are using an officially supported image.
|
||||
Also see "/var/log/cloud-init-output.log" in the guest.
|
||||
A possible workaround is to run "apt-get install sshfs" in the guest.
|
||||
`,
|
||||
},
|
||||
{
|
||||
description: "the guest agent to be running",
|
||||
script: `#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
sock="/run/user/$(id -u)/lima-guestagent.sock"
|
||||
if ! timeout 30s bash -c "until [[ -S \"${sock}\" ]]; do sleep 3; done"; then
|
||||
echo >&2 "lima-guestagent is not installed yet"
|
||||
exit 1
|
||||
fi
|
||||
`,
|
||||
debugHint: `The guest agent (/run/user/$UID/lima-guestagent.sock) does not seem running.
|
||||
Make sure that you are using an officially supported image.
|
||||
Also see "/var/log/cloud-init-output.log" in the guest.
|
||||
A possible workaround is to run "lima-guestagent install-systemd" in the guest.
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
var optionalRequirements = []requirement{
|
||||
{
|
||||
description: "containerd binaries to be installed",
|
||||
script: `#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
if ! timeout 30s bash -c "until command -v nerdctl; do sleep 3; done"; then
|
||||
echo >&2 "nerdctl is not installed yet"
|
||||
exit 1
|
||||
fi
|
||||
`,
|
||||
debugHint: `The nerdctl binary was not installed in the guest.
|
||||
Make sure that you are using an officially supported image.
|
||||
Also see "/var/log/cloud-init-output.log" in the guest.
|
||||
`,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package iso9660util
|
||||
|
||||
type ISO9660 struct {
|
||||
Name string // "cidata"
|
||||
FilesFromContent map[string]string // use string, not []byte, for debug printability
|
||||
FilesFromHostFilePath map[string]string
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package iso9660util
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
securejoin "github.com/cyphar/filepath-securejoin"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func Write(isoFilePath string, iso *ISO9660) error {
|
||||
td, err := ioutil.TempDir("", "lima-iso9660util")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(td)
|
||||
for k, v := range iso.FilesFromContent {
|
||||
if strings.ToLower(k) != k {
|
||||
return errors.Errorf("file name must be lower characters, got %q", k)
|
||||
}
|
||||
f, err := securejoin.SecureJoin(td, k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(f, []byte(v), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range iso.FilesFromHostFilePath {
|
||||
if strings.ToLower(k) != k {
|
||||
return errors.Errorf("file name must be lower characters, got %q", k)
|
||||
}
|
||||
f, err := securejoin.SecureJoin(td, k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command("cp", "-a", v, f)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return errors.Wrapf(err, "failed to run %v: %q", cmd.Args, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(isoFilePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("hdiutil", "makehybrid", "-iso", "-joliet",
|
||||
"-o", isoFilePath,
|
||||
"-default-volume-name", iso.Name,
|
||||
td)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return errors.Wrapf(err, "failed to run %v: %q", cmd.Args, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//+build !darwin
|
||||
|
||||
package iso9660util
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func Write(isoFilePath string, iso *ISO9660) error {
|
||||
return errors.Errorf("unsupported GOOS: %q", runtime.GOOS)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# Arch: "default", "x86_64", "aarch64".
|
||||
# "default" corresponds to the host architecture.
|
||||
arch: "default"
|
||||
|
||||
# An image must be Ubuntu, and must support cloud-init.
|
||||
# Debian and Fedora may also work, too, but untested.
|
||||
# Default: none (must be specified)
|
||||
images:
|
||||
# Try to use a local image first.
|
||||
- location: "~/Downloads/hirsute-server-cloudimg-amd64.img"
|
||||
arch: "x86_64"
|
||||
- location: "~/Downloads/hirsute-server-cloudimg-arm64.img"
|
||||
arch: "aarch64"
|
||||
|
||||
# Download the file from the internet when the local file is missing.
|
||||
- location: "https://cloud-images.ubuntu.com/hirsute/current/hirsute-server-cloudimg-amd64.img"
|
||||
arch: "x86_64"
|
||||
- location: "https://cloud-images.ubuntu.com/hirsute/current/hirsute-server-cloudimg-arm64.img"
|
||||
arch: "aarch64"
|
||||
|
||||
# CPUs: if you see performance issues, try limiting cpus to 1.
|
||||
# Default: 4
|
||||
cpus: 4
|
||||
|
||||
# Memory size
|
||||
# Default: "4GiB"
|
||||
memory: "4GiB"
|
||||
|
||||
# Disk size
|
||||
# Default: "100GiB"
|
||||
disk: "100GiB"
|
||||
|
||||
# Expose host directories to the guest
|
||||
# Default: none
|
||||
mounts:
|
||||
- location: "~"
|
||||
# CAUTION: `writable` SHOULD be false for the home directory.
|
||||
# Setting `writable` to true is possible, but untested and dangerous.
|
||||
writable: false
|
||||
- location: "/tmp/lima"
|
||||
writable: true
|
||||
|
||||
ssh:
|
||||
# A localhost port of the host. Forwarded to port 22 of the guest.
|
||||
# Currently, this port number has to be specified manually.
|
||||
# Default: none
|
||||
localPort: 60022
|
||||
|
||||
firmware:
|
||||
# Use legacy BIOS instead of UEFI.
|
||||
# Default: false
|
||||
legacyBIOS: false
|
||||
|
||||
#UNIMPLEMENTED| provision:
|
||||
#UNIMPLEMENTED| # `system` is executed with the root privilege
|
||||
#UNIMPLEMENTED| system: |
|
||||
#UNIMPLEMENTED| #!/bin/bash
|
||||
#UNIMPLEMENTED| set -eux -o pipefail
|
||||
#UNIMPLEMENTED| export DEBIAN_FRONTEND=noninteractive
|
||||
#UNIMPLEMENTED| apt-get install -y vim
|
||||
#UNIMPLEMENTED| # `user` is executed without the root privilege
|
||||
#UNIMPLEMENTED| user: |
|
||||
#UNIMPLEMENTED| #!/bin/bash
|
||||
#UNIMPLEMENTED| set -eux -o pipefail
|
||||
#UNIMPLEMENTED| cat <<EOF > ~/.vimrc
|
||||
#UNIMPLEMENTED| set number
|
||||
#UNIMPLEMENTED| EOF
|
||||
@@ -0,0 +1,33 @@
|
||||
package limayaml
|
||||
|
||||
import "runtime"
|
||||
|
||||
func FillDefault(y *LimaYAML) {
|
||||
y.Arch = resolveArch(y.Arch)
|
||||
for i := range y.Images {
|
||||
img := &y.Images[i]
|
||||
if img.Arch == "" {
|
||||
img.Arch = y.Arch
|
||||
}
|
||||
}
|
||||
if y.CPUs == 0 {
|
||||
y.CPUs = 4
|
||||
}
|
||||
if y.Memory == "" {
|
||||
y.Memory = "4GiB"
|
||||
}
|
||||
if y.Disk == "" {
|
||||
y.Disk = "100GiB"
|
||||
}
|
||||
}
|
||||
|
||||
func resolveArch(s string) Arch {
|
||||
if s == "" || s == "default" {
|
||||
if runtime.GOOS == "amd64" {
|
||||
return AARCH64
|
||||
} else {
|
||||
return X8664
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package limayaml
|
||||
|
||||
type LimaYAML struct {
|
||||
Arch Arch `yaml:"arch,omitempty"`
|
||||
Images []Image `yaml:"images"` // REQUIRED
|
||||
CPUs int `yaml:"cpus,omitempty"`
|
||||
Memory string `yaml:"memory,omitempty"` // go-units.RAMInBytes
|
||||
Disk string `yaml:"disk,omitempty"` // go-units.RAMInBytes
|
||||
Mounts []Mount `yaml:"mounts,omitempty"`
|
||||
SSH SSH `yaml:"ssh,omitempty"` // REQUIRED (FIXME)
|
||||
Firmware Firmware `yaml:"firmware,omitempty"`
|
||||
}
|
||||
|
||||
type Arch = string
|
||||
|
||||
const (
|
||||
X8664 Arch = "x86_64"
|
||||
AARCH64 Arch = "aarch64"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
Location string `yaml:"location"` // REQUIRED
|
||||
Arch string `yaml:"arch,omitempty"`
|
||||
}
|
||||
|
||||
type Mount struct {
|
||||
Location string `yaml:"location"` // REQUIRED
|
||||
Writable bool `yaml:"writable,omitempty"`
|
||||
}
|
||||
|
||||
type SSH struct {
|
||||
LocalPort int `yaml:"localPort,omitempty"` // REQUIRED (FIXME: auto assign)
|
||||
}
|
||||
|
||||
type Firmware struct {
|
||||
// LegacyBIOS disables UEFI if set.
|
||||
// LegacyBIOS is ignored for aarch64.
|
||||
LegacyBIOS bool `yaml:"legacyBIOS,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package limayaml
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// Load loads the yaml and fulfills unspecified fields with the default values.
|
||||
//
|
||||
// Load does not validate. Use Validate for validation.
|
||||
func Load(b []byte) (*LimaYAML, error) {
|
||||
var y LimaYAML
|
||||
if err := yaml.Unmarshal(b, &y); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
FillDefault(&y)
|
||||
return &y, nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package limayaml
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed default.TEMPLATE.yaml
|
||||
var DefaultTemplate []byte
|
||||
@@ -0,0 +1,13 @@
|
||||
package limayaml
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestDefaultTemplateYAML(t *testing.T) {
|
||||
_, err := Load(DefaultTemplate)
|
||||
assert.NilError(t, err)
|
||||
// Do not call Validate(y) here, as it fails when `~/lima` is missing
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package limayaml
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/localpathutil"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func Validate(y LimaYAML) error {
|
||||
FillDefault(&y)
|
||||
return ValidateRaw(y)
|
||||
}
|
||||
|
||||
func ValidateRaw(y LimaYAML) error {
|
||||
switch y.Arch {
|
||||
case X8664, AARCH64:
|
||||
default:
|
||||
return errors.Errorf("field `arch` must be %q or %q , got %q", X8664, AARCH64, y.Arch)
|
||||
}
|
||||
|
||||
if len(y.Images) == 0 {
|
||||
return errors.New("field `images` must be set")
|
||||
}
|
||||
for i, f := range y.Images {
|
||||
if !strings.Contains(f.Location, "://") {
|
||||
if _, err := localpathutil.Expand(f.Location); err != nil {
|
||||
return errors.Wrapf(err, "field `images[%d].location` refers to an invalid local file path: %q",
|
||||
i, f.Location)
|
||||
}
|
||||
// f.Location does NOT need to be accessible, so we do NOT check os.Stat(f.Location)
|
||||
}
|
||||
switch f.Arch {
|
||||
case X8664, AARCH64:
|
||||
default:
|
||||
return errors.Errorf("field `images.arch` must be %q or %q, got %q", X8664, AARCH64, f.Arch)
|
||||
}
|
||||
}
|
||||
|
||||
if y.CPUs == 0 {
|
||||
return errors.New("field `cpus` must be set")
|
||||
}
|
||||
|
||||
if _, err := units.RAMInBytes(y.Memory); err != nil {
|
||||
return errors.Wrapf(err, "field `memory` has an invalid value")
|
||||
}
|
||||
|
||||
if _, err := units.RAMInBytes(y.Disk); err != nil {
|
||||
return errors.Wrapf(err, "field `memory` has an invalid value")
|
||||
}
|
||||
|
||||
for i, f := range y.Mounts {
|
||||
if !filepath.IsAbs(f.Location) && !strings.HasPrefix(f.Location, "~") {
|
||||
return errors.Errorf("field `mounts[%d].location` must be an absolute path, got %q",
|
||||
i, f.Location)
|
||||
}
|
||||
loc, err := localpathutil.Expand(f.Location)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "field `mounts[%d].location` refers to an unexpandable path: %q",
|
||||
i, f.Location)
|
||||
}
|
||||
switch loc {
|
||||
case "/", "/bin", "/dev", "/etc", "/home", "/opt", "/sbin", "/tmp", "/usr", "/var":
|
||||
return errors.Errorf("field `mounts[%d].location` must not be a system path such as /etc or /usr", i)
|
||||
}
|
||||
|
||||
st, err := os.Stat(loc)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.Wrapf(err, "field `mounts[%d].location` refers to an inaccessible path: %q",
|
||||
i, f.Location)
|
||||
}
|
||||
} else if !st.IsDir() {
|
||||
return errors.Wrapf(err, "field `mounts[%d].location` refers to a non-directory path: %q",
|
||||
i, f.Location)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case y.SSH.LocalPort < 0:
|
||||
return errors.New("field `ssh.localPort` must be > 0")
|
||||
case y.SSH.LocalPort == 0:
|
||||
return errors.New("field `ssh.localPort` must be set, e.g, 60022 (FIXME: support automatic port assignment)")
|
||||
case y.SSH.LocalPort == 22:
|
||||
return errors.New("field `ssh.localPort` must not be 22")
|
||||
case y.SSH.LocalPort > 65535:
|
||||
return errors.New("field `ssh.localPort` must be < 65535")
|
||||
}
|
||||
|
||||
// y.Firmware.LegacyBIOS is ignored for aarch64, but not a fatal error.
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package localpathutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Expand expands a path like "~", "~/", "~/foo".
|
||||
// Paths like "~foo/bar" are unsupported.
|
||||
//
|
||||
// FIXME: is there an existing library for this?
|
||||
func Expand(orig string) (string, error) {
|
||||
s := orig
|
||||
if s == "" {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(s, "~") {
|
||||
if s == "~" || strings.HasPrefix(s, "~/") {
|
||||
s = strings.Replace(s, "~", homeDir, 1)
|
||||
} else {
|
||||
// Paths like "~foo/bar" are unsupported.
|
||||
return "", errors.Errorf("unexpandable path %q", orig)
|
||||
}
|
||||
}
|
||||
return filepath.Abs(s)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package qemu
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/limayaml"
|
||||
"github.com/AkihiroSuda/lima/pkg/localpathutil"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Name string
|
||||
InstanceDir string
|
||||
LimaYAML *limayaml.LimaYAML
|
||||
}
|
||||
|
||||
func EnsureDisk(cfg Config) error {
|
||||
diffDisk := filepath.Join(cfg.InstanceDir, "diffdisk")
|
||||
if _, err := os.Stat(diffDisk); err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||
// disk is already ensured
|
||||
return err
|
||||
}
|
||||
|
||||
baseDisk := filepath.Join(cfg.InstanceDir, "basedisk")
|
||||
if _, err := os.Stat(baseDisk); errors.Is(err, os.ErrNotExist) {
|
||||
baseDiskTmp := filepath.Join(cfg.InstanceDir, "basedisk.tmp")
|
||||
if err := os.RemoveAll(baseDiskTmp); err != nil {
|
||||
return err
|
||||
}
|
||||
var ensuredBaseDisk bool
|
||||
errs := make([]error, len(cfg.LimaYAML.Images))
|
||||
for i, f := range cfg.LimaYAML.Images {
|
||||
if f.Arch != cfg.LimaYAML.Arch {
|
||||
errs[i] = fmt.Errorf("unsupported arch: %q", f.Arch)
|
||||
continue
|
||||
}
|
||||
url := f.Location
|
||||
if !strings.Contains(url, "://") {
|
||||
expanded, err := localpathutil.Expand(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url = "file://" + expanded
|
||||
}
|
||||
cmd := exec.Command("curl", "-fSL", "-o", baseDiskTmp, url)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
logrus.Infof("Attempting to download the image from %q", url)
|
||||
if err := cmd.Run(); err != nil {
|
||||
errs[i] = errors.Wrapf(err, "failed to run %v", cmd.Args)
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(baseDiskTmp, baseDisk); err != nil {
|
||||
return err
|
||||
}
|
||||
ensuredBaseDisk = true
|
||||
break
|
||||
}
|
||||
if !ensuredBaseDisk {
|
||||
return errors.Errorf("failed to download the image, attempted %d candidates, errors=%v",
|
||||
len(cfg.LimaYAML.Images), errs)
|
||||
}
|
||||
}
|
||||
diskSize, err := units.RAMInBytes(cfg.LimaYAML.Disk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command("qemu-img", "create",
|
||||
"-f", "qcow2",
|
||||
"-b", baseDisk,
|
||||
diffDisk,
|
||||
strconv.Itoa(int(diskSize)))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return errors.Wrapf(err, "failed to run %v: %q", cmd.Args, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Cmdline(cfg Config) (string, []string, error) {
|
||||
y := cfg.LimaYAML
|
||||
exeBase := "qemu-system-" + y.Arch
|
||||
exe, err := exec.LookPath(exeBase)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var args []string
|
||||
|
||||
// Architecture
|
||||
accel := getAccel(y.Arch)
|
||||
switch y.Arch {
|
||||
case limayaml.X8664:
|
||||
// NOTE: "-cpu host" seems to cause kernel panic
|
||||
// (MacBookPro 2020, Intel(R) Core(TM) i7-1068NG7 CPU @ 2.30GHz, macOS 11.3, Ubuntu 21.04)
|
||||
args = append(args, "-cpu", "Haswell-v4")
|
||||
args = append(args, "-machine", "q35,accel="+accel)
|
||||
case limayaml.AARCH64:
|
||||
args = append(args, "-cpu", "cortex-a72")
|
||||
args = append(args, "-machine", "virt,accel="+accel)
|
||||
}
|
||||
|
||||
// SMP
|
||||
args = append(args, "-smp",
|
||||
fmt.Sprintf("%d,sockets=1,cores=%d,threads=1", y.CPUs, y.CPUs))
|
||||
|
||||
// Memory
|
||||
memBytes, err := units.RAMInBytes(y.Memory)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
args = append(args, "-m", strconv.Itoa(int(memBytes>>20)))
|
||||
|
||||
// Firmware
|
||||
if !y.Firmware.LegacyBIOS {
|
||||
firmware := filepath.Join(exe,
|
||||
fmt.Sprintf("../../share/qemu/edk2-%s-code.fd", y.Arch))
|
||||
if _, err := os.Stat(firmware); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
args = append(args, "-drive", fmt.Sprintf("if=pflash,format=raw,readonly,file=%s", firmware))
|
||||
} else if y.Arch != limayaml.X8664 {
|
||||
logrus.Warnf("field `firmware.legacyBIOS` is not supported for architecture %q, ignoring", y.Arch)
|
||||
}
|
||||
|
||||
// Root disk
|
||||
args = append(args, "-drive", fmt.Sprintf("file=%s,if=virtio", filepath.Join(cfg.InstanceDir, "diffdisk")))
|
||||
args = append(args, "-boot", "c")
|
||||
|
||||
// cloud-init
|
||||
args = append(args, "-cdrom", filepath.Join(cfg.InstanceDir, "cidata.iso"))
|
||||
|
||||
// Network
|
||||
// CIDR is intentionally hardcoded to 192.168.5.0/24, as each of QEMU has its own independent slirp network.
|
||||
// TODO: enable bridge (with sudo?)
|
||||
args = append(args, "-net", "nic,model=virtio")
|
||||
args = append(args, "-net", fmt.Sprintf("user,net=192.168.5.0/24,hostfwd=tcp:127.0.0.1:%d-:22", y.SSH.LocalPort))
|
||||
|
||||
// virtio-rng-pci acceralates starting up the OS, according to https://wiki.gentoo.org/wiki/QEMU/Options
|
||||
args = append(args, "-device", "virtio-rng-pci")
|
||||
|
||||
// Misc devices
|
||||
switch y.Arch {
|
||||
case limayaml.X8664:
|
||||
args = append(args, "-device", "virtio-vga")
|
||||
args = append(args, "-device", "virtio-keyboard-pci")
|
||||
args = append(args, "-device", "virtio-mouse-pci")
|
||||
default:
|
||||
// QEMU does not seem to support virtio-vga for aarch64
|
||||
args = append(args, "-vga", "none", "-device", "ramfb")
|
||||
args = append(args, "-device", "usb-ehci")
|
||||
args = append(args, "-device", "usb-kbd")
|
||||
args = append(args, "-device", "usb-mouse")
|
||||
}
|
||||
args = append(args, "-parallel", "none")
|
||||
|
||||
// We also want to enable vsock and virtfs here, but QEMU does not support vsock and virtfs for macOS hosts
|
||||
|
||||
// QEMU process
|
||||
args = append(args, "-name", "lima-"+cfg.Name)
|
||||
args = append(args, "-pidfile", filepath.Join(cfg.InstanceDir, "qemu-pid"))
|
||||
|
||||
return exe, args, nil
|
||||
}
|
||||
|
||||
func getAccel(arch limayaml.Arch) string {
|
||||
nativeX8664 := arch == limayaml.X8664 && runtime.GOARCH == "amd64"
|
||||
nativeAARCH64 := arch == limayaml.AARCH64 && runtime.GOARCH == "arm64"
|
||||
native := nativeX8664 || nativeAARCH64
|
||||
if native {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "hvf"
|
||||
case "linux":
|
||||
return "kvm"
|
||||
}
|
||||
}
|
||||
return "tcg"
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type PubKey struct {
|
||||
Filename string
|
||||
Content string
|
||||
}
|
||||
|
||||
// DefaultPubKeys finds ssh public keys from ~/.ssh
|
||||
func DefaultPubKeys() []PubKey {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
return nil
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(homeDir, ".ssh/*.pub"))
|
||||
if err != nil {
|
||||
logrus.Warn(err)
|
||||
return nil
|
||||
}
|
||||
var res []PubKey
|
||||
for _, f := range files {
|
||||
if !strings.HasSuffix(f, ".pub") {
|
||||
panic(errors.Errorf("unexpected ssh public key filename %q", f))
|
||||
}
|
||||
entry := PubKey{
|
||||
Filename: f,
|
||||
}
|
||||
if content, err := os.ReadFile(f); err == nil {
|
||||
entry.Content = strings.TrimSpace(string(content))
|
||||
} else {
|
||||
logrus.WithError(err).Warningf("failed to read ssh public key %q", f)
|
||||
}
|
||||
res = append(res, entry)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func SSHArgs(instDir string) ([]string, error) {
|
||||
controlSock := filepath.Join(instDir, "ssh.sock")
|
||||
maxSockLen := 104
|
||||
if runtime.GOOS == "linux" {
|
||||
maxSockLen = 108
|
||||
}
|
||||
if len(controlSock) >= maxSockLen {
|
||||
// If the instDir was under `~/Library/Application Support/Lima`,
|
||||
// we could only have 54 chars for the user name and the inst name at maximum:
|
||||
// len("/Users/32charsXXXXXXXXXXXXXXXXXXXXXXXXX/Library/Application Support/Lima/22charsXXXXXXXXXXXXXXX/ssh.sock") = 104
|
||||
// So we do not use `~/Library/Application Support`.
|
||||
return nil, errors.Errorf("socket path %q is too long: > UNIX_PATH_MAX=%d", controlSock, maxSockLen)
|
||||
}
|
||||
args := []string{
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPath=" + controlSock,
|
||||
"-o", "ControlPersist=5m",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "NoHostAuthenticationForLocalhost=yes",
|
||||
"-o", "GSSAPIAuthentication=no",
|
||||
"-o", "PreferredAuthentications=publickey",
|
||||
"-o", "Compression=no",
|
||||
"-o", "BatchMode=yes",
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package sshutil
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultPubKeys(t *testing.T) {
|
||||
keys := DefaultPubKeys()
|
||||
t.Logf("found %d public keys", len(keys))
|
||||
for _, key := range keys {
|
||||
t.Logf("%s: %q", key.Filename, key.Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package start
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/cidata"
|
||||
"github.com/AkihiroSuda/lima/pkg/hostagent"
|
||||
"github.com/AkihiroSuda/lima/pkg/iso9660util"
|
||||
"github.com/AkihiroSuda/lima/pkg/limayaml"
|
||||
"github.com/AkihiroSuda/lima/pkg/qemu"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func Start(ctx context.Context, instName, instDir string, y *limayaml.LimaYAML) error {
|
||||
cidataISO, err := cidata.GenerateISO9660(instName, y)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := iso9660util.Write(filepath.Join(instDir, "cidata.iso"), cidataISO); err != nil {
|
||||
return err
|
||||
}
|
||||
qCfg := qemu.Config{
|
||||
Name: instName,
|
||||
InstanceDir: instDir,
|
||||
LimaYAML: y,
|
||||
}
|
||||
if err := qemu.EnsureDisk(qCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
qExe, qArgs, err := qemu.Cmdline(qCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qCmd := exec.CommandContext(ctx, qExe, qArgs...)
|
||||
qCmd.Stdout = os.Stdout
|
||||
qCmd.Stderr = os.Stderr
|
||||
logrus.Info("Starting QEMU")
|
||||
logrus.Debugf("qCmd.Args: %v", qCmd.Args)
|
||||
if err := qCmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = qCmd.Process.Kill()
|
||||
}()
|
||||
|
||||
sshFixCmd := exec.Command("ssh-keygen",
|
||||
"-R", fmt.Sprintf("[127.0.0.1]:%d", y.SSH.LocalPort),
|
||||
"-R", fmt.Sprintf("[localhost]:%d", y.SSH.LocalPort),
|
||||
)
|
||||
|
||||
if out, err := sshFixCmd.CombinedOutput(); err != nil {
|
||||
return errors.Wrapf(err, "failed to run %v: %q", sshFixCmd.Args, string(out))
|
||||
}
|
||||
logrus.Infof("SSH: 127.0.0.1:%d", y.SSH.LocalPort)
|
||||
|
||||
hAgent, err := hostagent.New(y, instDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cErr := hAgent.Close(); cErr != nil {
|
||||
logrus.WithError(cErr).Warn("An error during shutting down the host agent")
|
||||
}
|
||||
}()
|
||||
if err := hAgent.Run(ctx); err == nil {
|
||||
logrus.Info("READY. Run `lima bash` to open the shell.")
|
||||
} else {
|
||||
logrus.WithError(err).Warn("DEGRADED. The VM seems running, but file sharing and port forwarding may not work.")
|
||||
}
|
||||
// TODO: daemonize the host process here
|
||||
return qCmd.Wait()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AkihiroSuda/lima/pkg/limayaml"
|
||||
"github.com/containerd/containerd/identifiers"
|
||||
)
|
||||
|
||||
const (
|
||||
// DotLima is a directory that appears under the home directory.
|
||||
DotLima = ".lima"
|
||||
// YAMLFileName appears under an instance dir.
|
||||
YAMLFileName = "lima.yaml"
|
||||
)
|
||||
|
||||
// LimaDir returns the abstract path of `~/.lima`.
|
||||
//
|
||||
// NOTE: We do not use `~/Library/Application Support/Lima` on macOS.
|
||||
// We use `~/.lima` so that we can have enough space for the length of the socket path,
|
||||
// which can be only 104 characters on macOS.
|
||||
func LimaDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(homeDir, DotLima)
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// Instances returns the names of the instances under LimaDir.
|
||||
func Instances() ([]string, error) {
|
||||
limaDir, err := LimaDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limaDirList, err := os.ReadDir(limaDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, f := range limaDirList {
|
||||
if strings.HasPrefix(f.Name(), ".") || strings.HasPrefix(f.Name(), "_") {
|
||||
continue
|
||||
}
|
||||
names = append(names, f.Name())
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// InstanceDir returns the instance dir.
|
||||
// InstanceDir does not check whether the instance exists
|
||||
func InstanceDir(name string) (string, error) {
|
||||
if err := identifiers.Validate(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
limaDir, err := LimaDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(limaDir, name)
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// InstanceNameFromInstanceDir extracts the instance name
|
||||
// from the path of the instance directory.
|
||||
// e.g. "foo" for "/Users/somebody/.lima/foo".
|
||||
func InstanceNameFromInstanceDir(s string) (string, error) {
|
||||
base := filepath.Base(s)
|
||||
return base, identifiers.Validate(base)
|
||||
}
|
||||
|
||||
// LoadYAMLByFilePath loads and validates the yaml.
|
||||
func LoadYAMLByFilePath(filePath string) (*limayaml.LimaYAML, error) {
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
yContent, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
y, err := limayaml.Load(yContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := limayaml.Validate(*y); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return y, nil
|
||||
}
|
||||
|
||||
// YAMLFilePathByInstanceName returns the yaml file path but does not
|
||||
// check whether it exists.
|
||||
func YAMLFilePathByInstanceName(instName string) (string, string, error) {
|
||||
instDir, err := InstanceDir(instName)
|
||||
if err != nil {
|
||||
return "", instDir, err
|
||||
}
|
||||
instYAMLPath := filepath.Join(instDir, YAMLFileName)
|
||||
return instYAMLPath, instDir, nil
|
||||
}
|
||||
|
||||
// LoadYAMLByInstanceName loads and validates the yaml.
|
||||
// LoadYAMLByInstanceName may return os.ErrNotExist
|
||||
func LoadYAMLByInstanceName(instName string) (*limayaml.LimaYAML, string, error) {
|
||||
instYAMLPath, instDir, err := YAMLFilePathByInstanceName(instName)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
y, err := LoadYAMLByFilePath(instYAMLPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return y, instDir, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package templateutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
func Execute(tmpl string, args interface{}) ([]byte, error) {
|
||||
x, err := template.New("").Parse(tmpl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := x.Execute(&b, args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
// Version is filled on compilation time
|
||||
Version = "<unknown>"
|
||||
)
|
||||
Reference in new issue
Block a user