Compare commits
No commits in common. "dev" and "v0.1.4" have entirely different histories.
44 changed files with 979 additions and 2015 deletions
|
|
@ -36,7 +36,7 @@ Three packages with a clear dependency direction: `devenv` -> `container` (root)
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- UK English (colour, organisation, honour)
|
- UK English (colour, organisation, honour)
|
||||||
- Tests use testify; naming convention: `TestSubject_Function_{Good,Bad,Ugly}`
|
- Tests use testify; naming convention: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (edge cases)
|
||||||
- Error wrapping: `core.E("Op", "message", err)`
|
- Error wrapping: `fmt.Errorf("context: %w", err)`
|
||||||
- Context propagation: all blocking operations take `context.Context` as first parameter
|
- Context propagation: all blocking operations take `context.Context` as first parameter
|
||||||
- Licence: EUPL-1.2
|
- Licence: EUPL-1.2
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Consumers of go-container
|
# Consumers of go-container
|
||||||
|
|
||||||
These modules import `dappco.re/go/core/container`:
|
These modules import `forge.lthn.ai/core/go-container`:
|
||||||
|
|
||||||
- core
|
- core
|
||||||
- go-devops
|
- go-devops
|
||||||
|
|
|
||||||
436
UPGRADE.md
436
UPGRADE.md
|
|
@ -1,436 +0,0 @@
|
||||||
# Upgrade Report: dappco.re/go/core v0.8.0-alpha.1
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Repository: `core/go-container`
|
|
||||||
- Branch: `agent/create-an-upgrade-plan-for-this-package`
|
|
||||||
- Requested target: `dappco.re/go/core v0.8.0-alpha.1`
|
|
||||||
- Consumers called out for break-risk review: `core`, `go-devops`
|
|
||||||
|
|
||||||
## Baseline Verification
|
|
||||||
|
|
||||||
- `go build ./...`: passed
|
|
||||||
- `go vet ./...`: passed
|
|
||||||
- `go test ./... -count=1 -timeout 120s`: passed
|
|
||||||
- `go test -cover ./...`: passed (`container` 81.7%, `cmd/vm` 0.0%, `devenv` 53.3%, `sources` 72.7%)
|
|
||||||
- `go mod tidy`: not run because this task is report-only and should not introduce dependency churn
|
|
||||||
|
|
||||||
## 1. go.mod Upgrade Plan
|
|
||||||
|
|
||||||
- Current core version: `dappco.re/go/core v0.5.0` at `go.mod:16`
|
|
||||||
- Required bump: `dappco.re/go/core v0.5.0` -> `dappco.re/go/core v0.8.0-alpha.1` at `go.mod:16`
|
|
||||||
- Direct `dappco.re/go/core/*` dependencies that should be compatibility-checked in the same upgrade pass:
|
|
||||||
- `go.mod:6` `dappco.re/go/core/i18n v0.2.0`
|
|
||||||
- `go.mod:7` `dappco.re/go/core/io v0.2.0`
|
|
||||||
- `go.mod:8` `dappco.re/go/core/log v0.1.0`
|
|
||||||
- Legacy `forge.lthn.ai` modules still present in `go.mod`; these should be reviewed during the core bump because they may pin older transitive core APIs:
|
|
||||||
- `go.mod:9` `forge.lthn.ai/core/cli v0.3.7`
|
|
||||||
- `go.mod:10` `forge.lthn.ai/core/config v0.1.8`
|
|
||||||
- `go.mod:17` `forge.lthn.ai/core/go v0.3.3`
|
|
||||||
- `go.mod:18` `forge.lthn.ai/core/go-i18n v0.1.7`
|
|
||||||
- `go.mod:19` `forge.lthn.ai/core/go-inference v0.1.6`
|
|
||||||
- `go.mod:20` `forge.lthn.ai/core/go-io v0.1.7`
|
|
||||||
- `go.mod:21` `forge.lthn.ai/core/go-log v0.0.4`
|
|
||||||
|
|
||||||
## 2. Banned Stdlib Imports
|
|
||||||
|
|
||||||
Each group lists every import site and the required Core replacement.
|
|
||||||
|
|
||||||
### `os`
|
|
||||||
|
|
||||||
- Replacement: Replace with core.Env/core.Fs
|
|
||||||
- `cmd/vm/cmd_container.go:7`
|
|
||||||
- `cmd/vm/cmd_templates.go:6`
|
|
||||||
- `devenv/claude.go:6`
|
|
||||||
- `devenv/config.go:4`
|
|
||||||
- `devenv/config_test.go:4`
|
|
||||||
- `devenv/devops.go:7`
|
|
||||||
- `devenv/devops_test.go:5`
|
|
||||||
- `devenv/images.go:7`
|
|
||||||
- `devenv/images_test.go:5`
|
|
||||||
- `devenv/serve.go:6`
|
|
||||||
- `devenv/serve_test.go:4`
|
|
||||||
- `devenv/shell.go:6`
|
|
||||||
- `devenv/ssh_utils.go:6`
|
|
||||||
- `devenv/test_test.go:4`
|
|
||||||
- `hypervisor.go:6`
|
|
||||||
- `linuxkit.go:8`
|
|
||||||
- `linuxkit_test.go:5`
|
|
||||||
- `sources/cdn.go:8`
|
|
||||||
- `sources/cdn_test.go:8`
|
|
||||||
- `sources/github.go:5`
|
|
||||||
- `state.go:5`
|
|
||||||
- `state_test.go:4`
|
|
||||||
- `templates.go:7`
|
|
||||||
- `templates_test.go:4`
|
|
||||||
|
|
||||||
### `os/exec`
|
|
||||||
|
|
||||||
- Replacement: No direct replacement was provided in the task; requires a manual audit for the v0.8.0-alpha.1 command-exec path
|
|
||||||
- `cmd/vm/cmd_templates.go:7`
|
|
||||||
- `devenv/claude.go:7`
|
|
||||||
- `devenv/devops_test.go:6`
|
|
||||||
- `devenv/serve.go:7`
|
|
||||||
- `devenv/shell.go:7`
|
|
||||||
- `devenv/ssh_utils.go:7`
|
|
||||||
- `hypervisor.go:7`
|
|
||||||
- `linuxkit.go:9`
|
|
||||||
- `linuxkit_test.go:6`
|
|
||||||
- `sources/github.go:6`
|
|
||||||
|
|
||||||
### `encoding/json`
|
|
||||||
|
|
||||||
- Replacement: Replace with core.JSONMarshalString/JSONUnmarshalString
|
|
||||||
- `devenv/images.go:5`
|
|
||||||
- `devenv/test.go:5`
|
|
||||||
- `state.go:4`
|
|
||||||
|
|
||||||
### `fmt`
|
|
||||||
|
|
||||||
- Replacement: Replace with core.Sprintf/core.Concat/core.E
|
|
||||||
- `cmd/vm/cmd_container.go:5`
|
|
||||||
- `cmd/vm/cmd_templates.go:5`
|
|
||||||
- `devenv/claude.go:5`
|
|
||||||
- `devenv/devops.go:6`
|
|
||||||
- `devenv/images.go:6`
|
|
||||||
- `devenv/serve.go:5`
|
|
||||||
- `devenv/shell.go:5`
|
|
||||||
- `devenv/ssh_utils.go:5`
|
|
||||||
- `hypervisor.go:5`
|
|
||||||
- `linuxkit.go:6`
|
|
||||||
- `sources/cdn.go:5`
|
|
||||||
- `sources/cdn_test.go:5`
|
|
||||||
|
|
||||||
### `errors`
|
|
||||||
|
|
||||||
- Replacement: Replace with core.E/core.Is
|
|
||||||
- No occurrences found
|
|
||||||
|
|
||||||
### `strings`
|
|
||||||
|
|
||||||
- Replacement: Replace with core.Contains/core.HasPrefix/core.Split/core.Trim/core.Replace
|
|
||||||
- `cmd/vm/cmd_container.go:8`
|
|
||||||
- `cmd/vm/cmd_templates.go:9`
|
|
||||||
- `devenv/claude.go:9`
|
|
||||||
- `devenv/ssh_utils.go:9`
|
|
||||||
- `devenv/test.go:7`
|
|
||||||
- `hypervisor.go:10`
|
|
||||||
- `sources/github.go:7`
|
|
||||||
- `templates.go:11`
|
|
||||||
- `templates_test.go:6`
|
|
||||||
|
|
||||||
### `path/filepath`
|
|
||||||
|
|
||||||
- Replacement: Replace with core.JoinPath/core.PathBase/core.PathDir
|
|
||||||
- `cmd/vm/cmd_templates.go:8`
|
|
||||||
- `devenv/claude.go:8`
|
|
||||||
- `devenv/config.go:5`
|
|
||||||
- `devenv/config_test.go:5`
|
|
||||||
- `devenv/devops.go:8`
|
|
||||||
- `devenv/devops_test.go:7`
|
|
||||||
- `devenv/images.go:8`
|
|
||||||
- `devenv/images_test.go:6`
|
|
||||||
- `devenv/serve.go:8`
|
|
||||||
- `devenv/serve_test.go:5`
|
|
||||||
- `devenv/ssh_utils.go:8`
|
|
||||||
- `devenv/test.go:6`
|
|
||||||
- `devenv/test_test.go:5`
|
|
||||||
- `hypervisor.go:8`
|
|
||||||
- `linuxkit_test.go:7`
|
|
||||||
- `sources/cdn.go:9`
|
|
||||||
- `sources/cdn_test.go:9`
|
|
||||||
- `state.go:6`
|
|
||||||
- `state_test.go:5`
|
|
||||||
- `templates.go:8`
|
|
||||||
- `templates_test.go:5`
|
|
||||||
|
|
||||||
## 3. Tests Not Matching `TestFile_Function_{Good,Bad,Ugly}`
|
|
||||||
|
|
||||||
- Total mismatches found: 236
|
|
||||||
|
|
||||||
- `devenv/claude_test.go:9` `TestClaudeOptions_Default`
|
|
||||||
- `devenv/claude_test.go:16` `TestClaudeOptions_Custom`
|
|
||||||
- `devenv/claude_test.go:27` `TestFormatAuthList_Good_NoAuth`
|
|
||||||
- `devenv/claude_test.go:33` `TestFormatAuthList_Good_Default`
|
|
||||||
- `devenv/claude_test.go:39` `TestFormatAuthList_Good_CustomAuth`
|
|
||||||
- `devenv/claude_test.go:47` `TestFormatAuthList_Good_MultipleAuth`
|
|
||||||
- `devenv/claude_test.go:55` `TestFormatAuthList_Good_EmptyAuth`
|
|
||||||
- `devenv/config_test.go:13` `TestDefaultConfig`
|
|
||||||
- `devenv/config_test.go:20` `TestConfigPath`
|
|
||||||
- `devenv/config_test.go:26` `TestLoadConfig_Good`
|
|
||||||
- `devenv/config_test.go:65` `TestLoadConfig_Bad`
|
|
||||||
- `devenv/config_test.go:82` `TestConfig_Struct`
|
|
||||||
- `devenv/config_test.go:105` `TestDefaultConfig_Complete`
|
|
||||||
- `devenv/config_test.go:114` `TestLoadConfig_Good_PartialConfig`
|
|
||||||
- `devenv/config_test.go:139` `TestLoadConfig_Good_AllSourceTypes`
|
|
||||||
- `devenv/config_test.go:208` `TestImagesConfig_Struct`
|
|
||||||
- `devenv/config_test.go:217` `TestGitHubConfig_Struct`
|
|
||||||
- `devenv/config_test.go:222` `TestRegistryConfig_Struct`
|
|
||||||
- `devenv/config_test.go:227` `TestCDNConfig_Struct`
|
|
||||||
- `devenv/config_test.go:232` `TestLoadConfig_Bad_UnreadableFile`
|
|
||||||
- `devenv/devops_test.go:18` `TestImageName`
|
|
||||||
- `devenv/devops_test.go:26` `TestImagesDir`
|
|
||||||
- `devenv/devops_test.go:48` `TestImagePath`
|
|
||||||
- `devenv/devops_test.go:58` `TestDefaultBootOptions`
|
|
||||||
- `devenv/devops_test.go:66` `TestIsInstalled_Bad`
|
|
||||||
- `devenv/devops_test.go:78` `TestIsInstalled_Good`
|
|
||||||
- `devenv/devops_test.go:142` `TestDevOps_Status_Good_NotInstalled`
|
|
||||||
- `devenv/devops_test.go:168` `TestDevOps_Status_Good_NoContainer`
|
|
||||||
- `devenv/devops_test.go:232` `TestDevOps_IsRunning_Bad_NotRunning`
|
|
||||||
- `devenv/devops_test.go:255` `TestDevOps_IsRunning_Bad_ContainerStopped`
|
|
||||||
- `devenv/devops_test.go:323` `TestDevOps_findContainer_Bad_NotFound`
|
|
||||||
- `devenv/devops_test.go:346` `TestDevOps_Stop_Bad_NotFound`
|
|
||||||
- `devenv/devops_test.go:369` `TestBootOptions_Custom`
|
|
||||||
- `devenv/devops_test.go:382` `TestDevStatus_Struct`
|
|
||||||
- `devenv/devops_test.go:403` `TestDevOps_Boot_Bad_NotInstalled`
|
|
||||||
- `devenv/devops_test.go:426` `TestDevOps_Boot_Bad_AlreadyRunning`
|
|
||||||
- `devenv/devops_test.go:465` `TestDevOps_Status_Good_WithImageVersion`
|
|
||||||
- `devenv/devops_test.go:501` `TestDevOps_findContainer_Good_MultipleContainers`
|
|
||||||
- `devenv/devops_test.go:546` `TestDevOps_Status_Good_ContainerWithUptime`
|
|
||||||
- `devenv/devops_test.go:583` `TestDevOps_IsRunning_Bad_DifferentContainerName`
|
|
||||||
- `devenv/devops_test.go:618` `TestDevOps_Boot_Good_FreshFlag`
|
|
||||||
- `devenv/devops_test.go:668` `TestDevOps_Stop_Bad_ContainerNotRunning`
|
|
||||||
- `devenv/devops_test.go:703` `TestDevOps_Boot_Good_FreshWithNoExisting`
|
|
||||||
- `devenv/devops_test.go:741` `TestImageName_Format`
|
|
||||||
- `devenv/devops_test.go:750` `TestDevOps_Install_Delegates`
|
|
||||||
- `devenv/devops_test.go:768` `TestDevOps_CheckUpdate_Delegates`
|
|
||||||
- `devenv/devops_test.go:786` `TestDevOps_Boot_Good_Success`
|
|
||||||
- `devenv/devops_test.go:818` `TestDevOps_Config`
|
|
||||||
- `devenv/images_test.go:16` `TestImageManager_Good_IsInstalled`
|
|
||||||
- `devenv/images_test.go:36` `TestNewImageManager_Good`
|
|
||||||
- `devenv/images_test.go:66` `TestManifest_Save`
|
|
||||||
- `devenv/images_test.go:94` `TestLoadManifest_Bad`
|
|
||||||
- `devenv/images_test.go:106` `TestCheckUpdate_Bad`
|
|
||||||
- `devenv/images_test.go:121` `TestNewImageManager_Good_AutoSource`
|
|
||||||
- `devenv/images_test.go:134` `TestNewImageManager_Good_UnknownSourceFallsToAuto`
|
|
||||||
- `devenv/images_test.go:147` `TestLoadManifest_Good_Empty`
|
|
||||||
- `devenv/images_test.go:159` `TestLoadManifest_Good_ExistingData`
|
|
||||||
- `devenv/images_test.go:174` `TestImageInfo_Struct`
|
|
||||||
- `devenv/images_test.go:187` `TestManifest_Save_Good_CreatesDirs`
|
|
||||||
- `devenv/images_test.go:207` `TestManifest_Save_Good_Overwrite`
|
|
||||||
- `devenv/images_test.go:239` `TestImageManager_Install_Bad_NoSourceAvailable`
|
|
||||||
- `devenv/images_test.go:256` `TestNewImageManager_Good_CreatesDir`
|
|
||||||
- `devenv/images_test.go:295` `TestImageManager_Install_Good_WithMockSource`
|
|
||||||
- `devenv/images_test.go:323` `TestImageManager_Install_Bad_DownloadError`
|
|
||||||
- `devenv/images_test.go:345` `TestImageManager_Install_Bad_VersionError`
|
|
||||||
- `devenv/images_test.go:367` `TestImageManager_Install_Good_SkipsUnavailableSource`
|
|
||||||
- `devenv/images_test.go:396` `TestImageManager_CheckUpdate_Good_WithMockSource`
|
|
||||||
- `devenv/images_test.go:426` `TestImageManager_CheckUpdate_Good_NoUpdate`
|
|
||||||
- `devenv/images_test.go:456` `TestImageManager_CheckUpdate_Bad_NoSource`
|
|
||||||
- `devenv/images_test.go:483` `TestImageManager_CheckUpdate_Bad_VersionError`
|
|
||||||
- `devenv/images_test.go:511` `TestImageManager_Install_Bad_EmptySources`
|
|
||||||
- `devenv/images_test.go:527` `TestImageManager_Install_Bad_AllUnavailable`
|
|
||||||
- `devenv/images_test.go:546` `TestImageManager_CheckUpdate_Good_FirstSourceUnavailable`
|
|
||||||
- `devenv/images_test.go:573` `TestManifest_Struct`
|
|
||||||
- `devenv/serve_test.go:12` `TestDetectServeCommand_Good_Laravel`
|
|
||||||
- `devenv/serve_test.go:21` `TestDetectServeCommand_Good_NodeDev`
|
|
||||||
- `devenv/serve_test.go:31` `TestDetectServeCommand_Good_NodeStart`
|
|
||||||
- `devenv/serve_test.go:41` `TestDetectServeCommand_Good_PHP`
|
|
||||||
- `devenv/serve_test.go:50` `TestDetectServeCommand_Good_GoMain`
|
|
||||||
- `devenv/serve_test.go:61` `TestDetectServeCommand_Good_GoWithoutMain`
|
|
||||||
- `devenv/serve_test.go:71` `TestDetectServeCommand_Good_Django`
|
|
||||||
- `devenv/serve_test.go:80` `TestDetectServeCommand_Good_Fallback`
|
|
||||||
- `devenv/serve_test.go:87` `TestDetectServeCommand_Good_Priority`
|
|
||||||
- `devenv/serve_test.go:99` `TestServeOptions_Default`
|
|
||||||
- `devenv/serve_test.go:105` `TestServeOptions_Custom`
|
|
||||||
- `devenv/serve_test.go:114` `TestHasFile_Good`
|
|
||||||
- `devenv/serve_test.go:123` `TestHasFile_Bad`
|
|
||||||
- `devenv/serve_test.go:129` `TestHasFile_Bad_Directory`
|
|
||||||
- `devenv/shell_test.go:9` `TestShellOptions_Default`
|
|
||||||
- `devenv/shell_test.go:15` `TestShellOptions_Console`
|
|
||||||
- `devenv/shell_test.go:23` `TestShellOptions_Command`
|
|
||||||
- `devenv/shell_test.go:31` `TestShellOptions_ConsoleWithCommand`
|
|
||||||
- `devenv/shell_test.go:40` `TestShellOptions_EmptyCommand`
|
|
||||||
- `devenv/test_test.go:11` `TestDetectTestCommand_Good_ComposerJSON`
|
|
||||||
- `devenv/test_test.go:21` `TestDetectTestCommand_Good_PackageJSON`
|
|
||||||
- `devenv/test_test.go:31` `TestDetectTestCommand_Good_GoMod`
|
|
||||||
- `devenv/test_test.go:41` `TestDetectTestCommand_Good_CoreTestYaml`
|
|
||||||
- `devenv/test_test.go:53` `TestDetectTestCommand_Good_Pytest`
|
|
||||||
- `devenv/test_test.go:63` `TestDetectTestCommand_Good_Taskfile`
|
|
||||||
- `devenv/test_test.go:73` `TestDetectTestCommand_Bad_NoFiles`
|
|
||||||
- `devenv/test_test.go:82` `TestDetectTestCommand_Good_Priority`
|
|
||||||
- `devenv/test_test.go:96` `TestLoadTestConfig_Good`
|
|
||||||
- `devenv/test_test.go:135` `TestLoadTestConfig_Bad_NotFound`
|
|
||||||
- `devenv/test_test.go:144` `TestHasPackageScript_Good`
|
|
||||||
- `devenv/test_test.go:156` `TestHasPackageScript_Bad_MissingScript`
|
|
||||||
- `devenv/test_test.go:165` `TestHasComposerScript_Good`
|
|
||||||
- `devenv/test_test.go:174` `TestHasComposerScript_Bad_MissingScript`
|
|
||||||
- `devenv/test_test.go:183` `TestTestConfig_Struct`
|
|
||||||
- `devenv/test_test.go:204` `TestTestCommand_Struct`
|
|
||||||
- `devenv/test_test.go:217` `TestTestOptions_Struct`
|
|
||||||
- `devenv/test_test.go:230` `TestDetectTestCommand_Good_TaskfileYml`
|
|
||||||
- `devenv/test_test.go:240` `TestDetectTestCommand_Good_Pyproject`
|
|
||||||
- `devenv/test_test.go:250` `TestHasPackageScript_Bad_NoFile`
|
|
||||||
- `devenv/test_test.go:258` `TestHasPackageScript_Bad_InvalidJSON`
|
|
||||||
- `devenv/test_test.go:267` `TestHasPackageScript_Bad_NoScripts`
|
|
||||||
- `devenv/test_test.go:276` `TestHasComposerScript_Bad_NoFile`
|
|
||||||
- `devenv/test_test.go:284` `TestHasComposerScript_Bad_InvalidJSON`
|
|
||||||
- `devenv/test_test.go:293` `TestHasComposerScript_Bad_NoScripts`
|
|
||||||
- `devenv/test_test.go:302` `TestLoadTestConfig_Bad_InvalidYAML`
|
|
||||||
- `devenv/test_test.go:314` `TestLoadTestConfig_Good_MinimalConfig`
|
|
||||||
- `devenv/test_test.go:332` `TestDetectTestCommand_Good_ComposerWithoutScript`
|
|
||||||
- `devenv/test_test.go:344` `TestDetectTestCommand_Good_PackageJSONWithoutScript`
|
|
||||||
- `hypervisor_test.go:23` `TestQemuHypervisor_Available_Bad_InvalidBinary`
|
|
||||||
- `hypervisor_test.go:47` `TestHyperkitHypervisor_Available_Bad_NotDarwin`
|
|
||||||
- `hypervisor_test.go:59` `TestHyperkitHypervisor_Available_Bad_InvalidBinary`
|
|
||||||
- `hypervisor_test.go:69` `TestIsKVMAvailable_Good`
|
|
||||||
- `hypervisor_test.go:83` `TestDetectHypervisor_Good`
|
|
||||||
- `hypervisor_test.go:98` `TestGetHypervisor_Good_Qemu`
|
|
||||||
- `hypervisor_test.go:110` `TestGetHypervisor_Good_QemuUppercase`
|
|
||||||
- `hypervisor_test.go:122` `TestGetHypervisor_Good_Hyperkit`
|
|
||||||
- `hypervisor_test.go:140` `TestGetHypervisor_Bad_Unknown`
|
|
||||||
- `hypervisor_test.go:147` `TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes`
|
|
||||||
- `hypervisor_test.go:175` `TestQemuHypervisor_BuildCommand_Good_QCow2Format`
|
|
||||||
- `hypervisor_test.go:195` `TestQemuHypervisor_BuildCommand_Good_VMDKFormat`
|
|
||||||
- `hypervisor_test.go:215` `TestQemuHypervisor_BuildCommand_Good_RawFormat`
|
|
||||||
- `hypervisor_test.go:235` `TestHyperkitHypervisor_BuildCommand_Good_WithPorts`
|
|
||||||
- `hypervisor_test.go:258` `TestHyperkitHypervisor_BuildCommand_Good_QCow2Format`
|
|
||||||
- `hypervisor_test.go:269` `TestHyperkitHypervisor_BuildCommand_Good_RawFormat`
|
|
||||||
- `hypervisor_test.go:280` `TestHyperkitHypervisor_BuildCommand_Good_NoPorts`
|
|
||||||
- `hypervisor_test.go:296` `TestQemuHypervisor_BuildCommand_Good_NoSSHPort`
|
|
||||||
- `hypervisor_test.go:312` `TestQemuHypervisor_BuildCommand_Bad_UnknownFormat`
|
|
||||||
- `hypervisor_test.go:323` `TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat`
|
|
||||||
- `hypervisor_test.go:339` `TestHyperkitHypervisor_BuildCommand_Good_ISOFormat`
|
|
||||||
- `linuxkit_test.go:76` `TestNewLinuxKitManagerWithHypervisor_Good`
|
|
||||||
- `linuxkit_test.go:89` `TestLinuxKitManager_Run_Good_Detached`
|
|
||||||
- `linuxkit_test.go:128` `TestLinuxKitManager_Run_Good_DefaultValues`
|
|
||||||
- `linuxkit_test.go:153` `TestLinuxKitManager_Run_Bad_ImageNotFound`
|
|
||||||
- `linuxkit_test.go:164` `TestLinuxKitManager_Run_Bad_UnsupportedFormat`
|
|
||||||
- `linuxkit_test.go:204` `TestLinuxKitManager_Stop_Bad_NotFound`
|
|
||||||
- `linuxkit_test.go:214` `TestLinuxKitManager_Stop_Bad_NotRunning`
|
|
||||||
- `linuxkit_test.go:251` `TestLinuxKitManager_List_Good_VerifiesRunningStatus`
|
|
||||||
- `linuxkit_test.go:303` `TestLinuxKitManager_Logs_Bad_NotFound`
|
|
||||||
- `linuxkit_test.go:313` `TestLinuxKitManager_Logs_Bad_NoLogFile`
|
|
||||||
- `linuxkit_test.go:336` `TestLinuxKitManager_Exec_Bad_NotFound`
|
|
||||||
- `linuxkit_test.go:346` `TestLinuxKitManager_Exec_Bad_NotRunning`
|
|
||||||
- `linuxkit_test.go:359` `TestDetectImageFormat_Good`
|
|
||||||
- `linuxkit_test.go:381` `TestDetectImageFormat_Bad_Unknown`
|
|
||||||
- `linuxkit_test.go:429` `TestLinuxKitManager_Logs_Good_Follow`
|
|
||||||
- `linuxkit_test.go:467` `TestFollowReader_Read_Good_WithData`
|
|
||||||
- `linuxkit_test.go:500` `TestFollowReader_Read_Good_ContextCancel`
|
|
||||||
- `linuxkit_test.go:544` `TestNewFollowReader_Bad_FileNotFound`
|
|
||||||
- `linuxkit_test.go:551` `TestLinuxKitManager_Run_Bad_BuildCommandError`
|
|
||||||
- `linuxkit_test.go:570` `TestLinuxKitManager_Run_Good_Foreground`
|
|
||||||
- `linuxkit_test.go:598` `TestLinuxKitManager_Stop_Good_ContextCancelled`
|
|
||||||
- `linuxkit_test.go:635` `TestIsProcessRunning_Good_ExistingProcess`
|
|
||||||
- `linuxkit_test.go:641` `TestIsProcessRunning_Bad_NonexistentProcess`
|
|
||||||
- `linuxkit_test.go:647` `TestLinuxKitManager_Run_Good_WithPortsAndVolumes`
|
|
||||||
- `linuxkit_test.go:676` `TestFollowReader_Read_Bad_ReaderError`
|
|
||||||
- `linuxkit_test.go:697` `TestLinuxKitManager_Run_Bad_StartError`
|
|
||||||
- `linuxkit_test.go:718` `TestLinuxKitManager_Run_Bad_ForegroundStartError`
|
|
||||||
- `linuxkit_test.go:739` `TestLinuxKitManager_Run_Good_ForegroundWithError`
|
|
||||||
- `linuxkit_test.go:762` `TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning`
|
|
||||||
- `sources/cdn_test.go:16` `TestCDNSource_Good_Available`
|
|
||||||
- `sources/cdn_test.go:26` `TestCDNSource_Bad_NoURL`
|
|
||||||
- `sources/cdn_test.go:118` `TestCDNSource_LatestVersion_Bad_NoManifest`
|
|
||||||
- `sources/cdn_test.go:134` `TestCDNSource_LatestVersion_Bad_ServerError`
|
|
||||||
- `sources/cdn_test.go:150` `TestCDNSource_Download_Good_NoProgress`
|
|
||||||
- `sources/cdn_test.go:174` `TestCDNSource_Download_Good_LargeFile`
|
|
||||||
- `sources/cdn_test.go:206` `TestCDNSource_Download_Bad_HTTPErrorCodes`
|
|
||||||
- `sources/cdn_test.go:238` `TestCDNSource_InterfaceCompliance`
|
|
||||||
- `sources/cdn_test.go:243` `TestCDNSource_Config`
|
|
||||||
- `sources/cdn_test.go:254` `TestNewCDNSource_Good`
|
|
||||||
- `sources/cdn_test.go:268` `TestCDNSource_Download_Good_CreatesDestDir`
|
|
||||||
- `sources/cdn_test.go:294` `TestSourceConfig_Struct`
|
|
||||||
- `sources/github_test.go:9` `TestGitHubSource_Good_Available`
|
|
||||||
- `sources/github_test.go:23` `TestGitHubSource_Name`
|
|
||||||
- `sources/github_test.go:28` `TestGitHubSource_Config`
|
|
||||||
- `sources/github_test.go:40` `TestGitHubSource_Good_Multiple`
|
|
||||||
- `sources/github_test.go:51` `TestNewGitHubSource_Good`
|
|
||||||
- `sources/github_test.go:65` `TestGitHubSource_InterfaceCompliance`
|
|
||||||
- `sources/source_test.go:9` `TestSourceConfig_Empty`
|
|
||||||
- `sources/source_test.go:17` `TestSourceConfig_Complete`
|
|
||||||
- `sources/source_test.go:31` `TestImageSource_Interface`
|
|
||||||
- `state_test.go:13` `TestNewState_Good`
|
|
||||||
- `state_test.go:21` `TestLoadState_Good_NewFile`
|
|
||||||
- `state_test.go:33` `TestLoadState_Good_ExistingFile`
|
|
||||||
- `state_test.go:64` `TestLoadState_Bad_InvalidJSON`
|
|
||||||
- `state_test.go:142` `TestState_Get_Bad_NotFound`
|
|
||||||
- `state_test.go:162` `TestState_SaveState_Good_CreatesDirectory`
|
|
||||||
- `state_test.go:177` `TestDefaultStateDir_Good`
|
|
||||||
- `state_test.go:183` `TestDefaultStatePath_Good`
|
|
||||||
- `state_test.go:189` `TestDefaultLogsDir_Good`
|
|
||||||
- `state_test.go:195` `TestLogPath_Good`
|
|
||||||
- `state_test.go:201` `TestEnsureLogsDir_Good`
|
|
||||||
- `state_test.go:211` `TestGenerateID_Good`
|
|
||||||
- `templates_test.go:13` `TestListTemplates_Good`
|
|
||||||
- `templates_test.go:44` `TestGetTemplate_Good_CoreDev`
|
|
||||||
- `templates_test.go:55` `TestGetTemplate_Good_ServerPhp`
|
|
||||||
- `templates_test.go:66` `TestGetTemplate_Bad_NotFound`
|
|
||||||
- `templates_test.go:73` `TestApplyVariables_Good_SimpleSubstitution`
|
|
||||||
- `templates_test.go:86` `TestApplyVariables_Good_WithDefaults`
|
|
||||||
- `templates_test.go:99` `TestApplyVariables_Good_AllDefaults`
|
|
||||||
- `templates_test.go:109` `TestApplyVariables_Good_MixedSyntax`
|
|
||||||
- `templates_test.go:128` `TestApplyVariables_Good_EmptyDefault`
|
|
||||||
- `templates_test.go:138` `TestApplyVariables_Bad_MissingRequired`
|
|
||||||
- `templates_test.go:149` `TestApplyVariables_Bad_MultipleMissing`
|
|
||||||
- `templates_test.go:164` `TestApplyTemplate_Good`
|
|
||||||
- `templates_test.go:178` `TestApplyTemplate_Bad_TemplateNotFound`
|
|
||||||
- `templates_test.go:189` `TestApplyTemplate_Bad_MissingVariable`
|
|
||||||
- `templates_test.go:199` `TestExtractVariables_Good`
|
|
||||||
- `templates_test.go:221` `TestExtractVariables_Good_NoVariables`
|
|
||||||
- `templates_test.go:230` `TestExtractVariables_Good_OnlyDefaults`
|
|
||||||
- `templates_test.go:241` `TestScanUserTemplates_Good`
|
|
||||||
- `templates_test.go:265` `TestScanUserTemplates_Good_MultipleTemplates`
|
|
||||||
- `templates_test.go:287` `TestScanUserTemplates_Good_EmptyDirectory`
|
|
||||||
- `templates_test.go:295` `TestScanUserTemplates_Bad_NonexistentDirectory`
|
|
||||||
- `templates_test.go:301` `TestExtractTemplateDescription_Good`
|
|
||||||
- `templates_test.go:318` `TestExtractTemplateDescription_Good_NoComments`
|
|
||||||
- `templates_test.go:333` `TestExtractTemplateDescription_Bad_FileNotFound`
|
|
||||||
- `templates_test.go:339` `TestVariablePatternEdgeCases_Good`
|
|
||||||
- `templates_test.go:387` `TestScanUserTemplates_Good_SkipsBuiltinNames`
|
|
||||||
- `templates_test.go:405` `TestScanUserTemplates_Good_SkipsDirectories`
|
|
||||||
- `templates_test.go:422` `TestScanUserTemplates_Good_YamlExtension`
|
|
||||||
- `templates_test.go:443` `TestExtractTemplateDescription_Good_EmptyComment`
|
|
||||||
- `templates_test.go:461` `TestExtractTemplateDescription_Good_MultipleEmptyComments`
|
|
||||||
- `templates_test.go:481` `TestScanUserTemplates_Good_DefaultDescription`
|
|
||||||
|
|
||||||
## 4. Exported Functions Missing Usage-Example Doc Comments
|
|
||||||
|
|
||||||
- Total exported functions missing a usage example marker: 38
|
|
||||||
- Note: every function listed below has a doc comment, but none of the comments include an obvious usage example marker such as `Usage:` or `Example:`.
|
|
||||||
|
|
||||||
- `cmd/vm/cmd_templates.go:150` `RunFromTemplate` (missing usage example)
|
|
||||||
- `cmd/vm/cmd_templates.go:296` `ParseVarFlags` (missing usage example)
|
|
||||||
- `cmd/vm/cmd_vm.go:28` `AddVMCommands` (missing usage example)
|
|
||||||
- `container.go:84` `GenerateID` (missing usage example)
|
|
||||||
- `devenv/config.go:41` `DefaultConfig` (missing usage example)
|
|
||||||
- `devenv/config.go:57` `ConfigPath` (missing usage example)
|
|
||||||
- `devenv/config.go:67` `LoadConfig` (missing usage example)
|
|
||||||
- `devenv/devops.go:31` `New` (missing usage example)
|
|
||||||
- `devenv/devops.go:56` `ImageName` (missing usage example)
|
|
||||||
- `devenv/devops.go:61` `ImagesDir` (missing usage example)
|
|
||||||
- `devenv/devops.go:73` `ImagePath` (missing usage example)
|
|
||||||
- `devenv/devops.go:109` `DefaultBootOptions` (missing usage example)
|
|
||||||
- `devenv/images.go:40` `NewImageManager` (missing usage example)
|
|
||||||
- `devenv/serve.go:75` `DetectServeCommand` (missing usage example)
|
|
||||||
- `devenv/test.go:75` `DetectTestCommand` (missing usage example)
|
|
||||||
- `devenv/test.go:115` `LoadTestConfig` (missing usage example)
|
|
||||||
- `hypervisor.go:50` `NewQemuHypervisor` (missing usage example)
|
|
||||||
- `hypervisor.go:155` `NewHyperkitHypervisor` (missing usage example)
|
|
||||||
- `hypervisor.go:222` `DetectImageFormat` (missing usage example)
|
|
||||||
- `hypervisor.go:239` `DetectHypervisor` (missing usage example)
|
|
||||||
- `hypervisor.go:258` `GetHypervisor` (missing usage example)
|
|
||||||
- `linuxkit.go:25` `NewLinuxKitManager` (missing usage example)
|
|
||||||
- `linuxkit.go:49` `NewLinuxKitManagerWithHypervisor` (missing usage example)
|
|
||||||
- `sources/cdn.go:24` `NewCDNSource` (missing usage example)
|
|
||||||
- `sources/github.go:22` `NewGitHubSource` (missing usage example)
|
|
||||||
- `state.go:22` `DefaultStateDir` (missing usage example)
|
|
||||||
- `state.go:31` `DefaultStatePath` (missing usage example)
|
|
||||||
- `state.go:40` `DefaultLogsDir` (missing usage example)
|
|
||||||
- `state.go:49` `NewState` (missing usage example)
|
|
||||||
- `state.go:58` `LoadState` (missing usage example)
|
|
||||||
- `state.go:157` `LogPath` (missing usage example)
|
|
||||||
- `state.go:166` `EnsureLogsDir` (missing usage example)
|
|
||||||
- `templates.go:47` `ListTemplates` (missing usage example)
|
|
||||||
- `templates.go:52` `ListTemplatesIter` (missing usage example)
|
|
||||||
- `templates.go:75` `GetTemplate` (missing usage example)
|
|
||||||
- `templates.go:107` `ApplyTemplate` (missing usage example)
|
|
||||||
- `templates.go:120` `ApplyVariables` (missing usage example)
|
|
||||||
- `templates.go:169` `ExtractVariables` (missing usage example)
|
|
||||||
|
|
||||||
## Risk Notes
|
|
||||||
|
|
||||||
- Breaking-change surface is moderate because this repo is consumed by two modules: `core` and `go-devops`.
|
|
||||||
- The highest-effort part of the upgrade is not the version bump itself; it is the repo-wide removal of banned stdlib imports, especially the current `os/exec` usage across runtime code and tests.
|
|
||||||
- The doc-comment and test-renaming work is mechanically simple, but it touches many files and will create broad diff surface for downstream review.
|
|
||||||
|
|
@ -2,17 +2,18 @@ package vm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
goio "io"
|
goio "io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"dappco.re/go/core/container"
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
"dappco.re/go/core/i18n"
|
|
||||||
"dappco.re/go/core/io"
|
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"forge.lthn.ai/core/go-container"
|
||||||
|
"forge.lthn.ai/core/go-i18n"
|
||||||
|
"forge.lthn.ai/core/go-io"
|
||||||
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -81,12 +82,12 @@ func runContainer(image, name string, detach bool, memory, cpus, sshPort int) er
|
||||||
SSHPort: sshPort,
|
SSHPort: sshPort,
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.Label("image")), image)
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("image")), image)
|
||||||
if name != "" {
|
if name != "" {
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.name")), name)
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.name")), name)
|
||||||
}
|
}
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
|
||||||
core.Println()
|
fmt.Println()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
c, err := manager.Run(ctx, image, opts)
|
c, err := manager.Run(ctx, image, opts)
|
||||||
|
|
@ -95,14 +96,13 @@ func runContainer(image, name string, detach bool, memory, cpus, sshPort int) er
|
||||||
}
|
}
|
||||||
|
|
||||||
if detach {
|
if detach {
|
||||||
core.Print(nil, "%s %s", successStyle.Render(i18n.Label("started")), c.ID)
|
fmt.Printf("%s %s\n", successStyle.Render(i18n.Label("started")), c.ID)
|
||||||
core.Print(nil, "%s %d", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
|
fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
|
||||||
core.Println()
|
fmt.Println()
|
||||||
core.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]}))
|
fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]}))
|
||||||
core.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]}))
|
fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]}))
|
||||||
} else {
|
} else {
|
||||||
core.Println()
|
fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -151,16 +151,16 @@ func listContainers(all bool) error {
|
||||||
|
|
||||||
if len(containers) == 0 {
|
if len(containers) == 0 {
|
||||||
if all {
|
if all {
|
||||||
core.Println(i18n.T("cmd.vm.ps.no_containers"))
|
fmt.Println(i18n.T("cmd.vm.ps.no_containers"))
|
||||||
} else {
|
} else {
|
||||||
core.Println(i18n.T("cmd.vm.ps.no_running"))
|
fmt.Println(i18n.T("cmd.vm.ps.no_running"))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
w := tabwriter.NewWriter(proc.Stdout, 0, 0, 2, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
core.Print(w, "%s", i18n.T("cmd.vm.ps.header"))
|
_, _ = fmt.Fprintln(w, i18n.T("cmd.vm.ps.header"))
|
||||||
core.Print(w, "%s", "--\t----\t-----\t------\t-------\t---")
|
_, _ = fmt.Fprintln(w, "--\t----\t-----\t------\t-------\t---")
|
||||||
|
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
// Shorten image path
|
// Shorten image path
|
||||||
|
|
@ -183,7 +183,7 @@ func listContainers(all bool) error {
|
||||||
status = errorStyle.Render(status)
|
status = errorStyle.Render(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Print(w, "%s\t%s\t%s\t%s\t%s\t%d",
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n",
|
||||||
c.ID[:8], c.Name, imageName, status, duration, c.PID)
|
c.ID[:8], c.Name, imageName, status, duration, c.PID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,15 +193,15 @@ func listContainers(all bool) error {
|
||||||
|
|
||||||
func formatDuration(d time.Duration) string {
|
func formatDuration(d time.Duration) string {
|
||||||
if d < time.Minute {
|
if d < time.Minute {
|
||||||
return core.Sprintf("%ds", int(d.Seconds()))
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||||
}
|
}
|
||||||
if d < time.Hour {
|
if d < time.Hour {
|
||||||
return core.Sprintf("%dm", int(d.Minutes()))
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||||
}
|
}
|
||||||
if d < 24*time.Hour {
|
if d < 24*time.Hour {
|
||||||
return core.Sprintf("%dh", int(d.Hours()))
|
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||||
}
|
}
|
||||||
return core.Sprintf("%dd", int(d.Hours()/24))
|
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||||
}
|
}
|
||||||
|
|
||||||
// addVMStopCommand adds the 'stop' command under vm.
|
// addVMStopCommand adds the 'stop' command under vm.
|
||||||
|
|
@ -233,14 +233,14 @@ func stopContainer(id string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.stop.stopping")), fullID[:8])
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.stop.stopping")), fullID[:8])
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := manager.Stop(ctx, fullID); err != nil {
|
if err := manager.Stop(ctx, fullID); err != nil {
|
||||||
return coreerr.E("stopContainer", i18n.T("i18n.fail.stop", "container"), err)
|
return coreerr.E("stopContainer", i18n.T("i18n.fail.stop", "container"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Print(nil, "%s", successStyle.Render(i18n.T("common.status.stopped")))
|
fmt.Printf("%s\n", successStyle.Render(i18n.T("common.status.stopped")))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,7 +254,7 @@ func resolveContainerID(manager *container.LinuxKitManager, partialID string) (s
|
||||||
|
|
||||||
var matches []*container.Container
|
var matches []*container.Container
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
if core.HasPrefix(c.ID, partialID) || core.HasPrefix(c.Name, partialID) {
|
if strings.HasPrefix(c.ID, partialID) || strings.HasPrefix(c.Name, partialID) {
|
||||||
matches = append(matches, c)
|
matches = append(matches, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +308,7 @@ func viewLogs(id string, follow bool) error {
|
||||||
}
|
}
|
||||||
defer func() { _ = reader.Close() }()
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
_, err = goio.Copy(proc.Stdout, reader)
|
_, err = goio.Copy(os.Stdout, reader)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,18 @@ package vm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"dappco.re/go/core/container"
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
"dappco.re/go/core/i18n"
|
|
||||||
"dappco.re/go/core/io"
|
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"forge.lthn.ai/core/go-container"
|
||||||
|
"forge.lthn.ai/core/go-i18n"
|
||||||
|
"forge.lthn.ai/core/go-io"
|
||||||
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// addVMTemplatesCommand adds the 'templates' command under vm.
|
// addVMTemplatesCommand adds the 'templates' command under vm.
|
||||||
|
|
@ -70,30 +72,29 @@ func listTemplates() error {
|
||||||
templates := container.ListTemplates()
|
templates := container.ListTemplates()
|
||||||
|
|
||||||
if len(templates) == 0 {
|
if len(templates) == 0 {
|
||||||
core.Println(i18n.T("cmd.vm.templates.no_templates"))
|
fmt.Println(i18n.T("cmd.vm.templates.no_templates"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Print(nil, "%s", repoNameStyle.Render(i18n.T("cmd.vm.templates.title")))
|
fmt.Printf("%s\n\n", repoNameStyle.Render(i18n.T("cmd.vm.templates.title")))
|
||||||
core.Println()
|
|
||||||
|
|
||||||
w := tabwriter.NewWriter(proc.Stdout, 0, 0, 2, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
core.Print(w, "%s", i18n.T("cmd.vm.templates.header"))
|
_, _ = fmt.Fprintln(w, i18n.T("cmd.vm.templates.header"))
|
||||||
core.Print(w, "%s", "----\t-----------")
|
_, _ = fmt.Fprintln(w, "----\t-----------")
|
||||||
|
|
||||||
for _, tmpl := range templates {
|
for _, tmpl := range templates {
|
||||||
desc := tmpl.Description
|
desc := tmpl.Description
|
||||||
if len(desc) > 60 {
|
if len(desc) > 60 {
|
||||||
desc = desc[:57] + "..."
|
desc = desc[:57] + "..."
|
||||||
}
|
}
|
||||||
core.Print(w, "%s\t%s", repoNameStyle.Render(tmpl.Name), desc)
|
_, _ = fmt.Fprintf(w, "%s\t%s\n", repoNameStyle.Render(tmpl.Name), desc)
|
||||||
}
|
}
|
||||||
_ = w.Flush()
|
_ = w.Flush()
|
||||||
|
|
||||||
core.Println()
|
fmt.Println()
|
||||||
core.Print(nil, "%s %s", i18n.T("cmd.vm.templates.hint.show"), dimStyle.Render("core vm templates show <name>"))
|
fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.show"), dimStyle.Render("core vm templates show <name>"))
|
||||||
core.Print(nil, "%s %s", i18n.T("cmd.vm.templates.hint.vars"), dimStyle.Render("core vm templates vars <name>"))
|
fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.vars"), dimStyle.Render("core vm templates vars <name>"))
|
||||||
core.Print(nil, "%s %s", i18n.T("cmd.vm.templates.hint.run"), dimStyle.Render("core vm run --template <name> --var SSH_KEY=\"...\""))
|
fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.run"), dimStyle.Render("core vm run --template <name> --var SSH_KEY=\"...\""))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -104,9 +105,8 @@ func showTemplate(name string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name))
|
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name))
|
||||||
core.Println()
|
fmt.Println(content)
|
||||||
core.Println(content)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -119,39 +119,34 @@ func showTemplateVars(name string) error {
|
||||||
|
|
||||||
required, optional := container.ExtractVariables(content)
|
required, optional := container.ExtractVariables(content)
|
||||||
|
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name))
|
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name))
|
||||||
core.Println()
|
|
||||||
|
|
||||||
if len(required) > 0 {
|
if len(required) > 0 {
|
||||||
core.Print(nil, "%s", errorStyle.Render(i18n.T("cmd.vm.templates.vars.required")))
|
fmt.Printf("%s\n", errorStyle.Render(i18n.T("cmd.vm.templates.vars.required")))
|
||||||
for _, v := range required {
|
for _, v := range required {
|
||||||
core.Print(nil, " %s", varStyle.Render("${"+v+"}"))
|
fmt.Printf(" %s\n", varStyle.Render("${"+v+"}"))
|
||||||
}
|
}
|
||||||
core.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(optional) > 0 {
|
if len(optional) > 0 {
|
||||||
core.Print(nil, "%s", successStyle.Render(i18n.T("cmd.vm.templates.vars.optional")))
|
fmt.Printf("%s\n", successStyle.Render(i18n.T("cmd.vm.templates.vars.optional")))
|
||||||
for v, def := range optional {
|
for v, def := range optional {
|
||||||
core.Print(nil, " %s = %s",
|
fmt.Printf(" %s = %s\n",
|
||||||
varStyle.Render("${"+v+"}"),
|
varStyle.Render("${"+v+"}"),
|
||||||
defaultStyle.Render(def))
|
defaultStyle.Render(def))
|
||||||
}
|
}
|
||||||
core.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(required) == 0 && len(optional) == 0 {
|
if len(required) == 0 && len(optional) == 0 {
|
||||||
core.Println(i18n.T("cmd.vm.templates.vars.none"))
|
fmt.Println(i18n.T("cmd.vm.templates.vars.none"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunFromTemplate builds and runs a LinuxKit image from a template.
|
// RunFromTemplate builds and runs a LinuxKit image from a template.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// err := RunFromTemplate("core-dev", vars, runOpts)
|
|
||||||
func RunFromTemplate(templateName string, vars map[string]string, runOpts container.RunOptions) error {
|
func RunFromTemplate(templateName string, vars map[string]string, runOpts container.RunOptions) error {
|
||||||
// Apply template with variables
|
// Apply template with variables
|
||||||
content, err := container.ApplyTemplate(templateName, vars)
|
content, err := container.ApplyTemplate(templateName, vars)
|
||||||
|
|
@ -160,23 +155,23 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a temporary directory for the build
|
// Create a temporary directory for the build
|
||||||
tmpDir, err := coreutil.MkdirTemp("core-linuxkit-")
|
tmpDir, err := os.MkdirTemp("", "core-linuxkit-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "create temp directory"}), err)
|
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "create temp directory"}), err)
|
||||||
}
|
}
|
||||||
defer func() { _ = io.Local.DeleteAll(tmpDir) }()
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||||
|
|
||||||
// Write the YAML file
|
// Write the YAML file
|
||||||
yamlPath := coreutil.JoinPath(tmpDir, core.Concat(templateName, ".yml"))
|
yamlPath := filepath.Join(tmpDir, templateName+".yml")
|
||||||
if err := io.Local.Write(yamlPath, content); err != nil {
|
if err := io.Local.Write(yamlPath, content); err != nil {
|
||||||
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "write template"}), err)
|
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "write template"}), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(templateName))
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(templateName))
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.building")), yamlPath)
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.building")), yamlPath)
|
||||||
|
|
||||||
// Build the image using linuxkit
|
// Build the image using linuxkit
|
||||||
outputPath := coreutil.JoinPath(tmpDir, templateName)
|
outputPath := filepath.Join(tmpDir, templateName)
|
||||||
if err := buildLinuxKitImage(yamlPath, outputPath); err != nil {
|
if err := buildLinuxKitImage(yamlPath, outputPath); err != nil {
|
||||||
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "build image"}), err)
|
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "build image"}), err)
|
||||||
}
|
}
|
||||||
|
|
@ -187,8 +182,8 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai
|
||||||
return coreerr.E("RunFromTemplate", i18n.T("cmd.vm.error.no_image_found"), nil)
|
return coreerr.E("RunFromTemplate", i18n.T("cmd.vm.error.no_image_found"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.image")), imagePath)
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("common.label.image")), imagePath)
|
||||||
core.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Run the image
|
// Run the image
|
||||||
manager, err := container.NewLinuxKitManager(io.Local)
|
manager, err := container.NewLinuxKitManager(io.Local)
|
||||||
|
|
@ -196,8 +191,8 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai
|
||||||
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "initialize container manager"}), err)
|
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "initialize container manager"}), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
|
||||||
core.Println()
|
fmt.Println()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
c, err := manager.Run(ctx, imagePath, runOpts)
|
c, err := manager.Run(ctx, imagePath, runOpts)
|
||||||
|
|
@ -206,14 +201,13 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai
|
||||||
}
|
}
|
||||||
|
|
||||||
if runOpts.Detach {
|
if runOpts.Detach {
|
||||||
core.Print(nil, "%s %s", successStyle.Render(i18n.T("common.label.started")), c.ID)
|
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("common.label.started")), c.ID)
|
||||||
core.Print(nil, "%s %d", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
|
fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
|
||||||
core.Println()
|
fmt.Println()
|
||||||
core.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]}))
|
fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]}))
|
||||||
core.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]}))
|
fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]}))
|
||||||
} else {
|
} else {
|
||||||
core.Println()
|
fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
|
||||||
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -229,13 +223,13 @@ func buildLinuxKitImage(yamlPath, outputPath string) error {
|
||||||
|
|
||||||
// Build the image
|
// Build the image
|
||||||
// linuxkit build --format iso-bios --name <output> <yaml>
|
// linuxkit build --format iso-bios --name <output> <yaml>
|
||||||
cmd := proc.NewCommand(lkPath, "build",
|
cmd := exec.Command(lkPath, "build",
|
||||||
"--format", "iso-bios",
|
"--format", "iso-bios",
|
||||||
"--name", outputPath,
|
"--name", outputPath,
|
||||||
yamlPath)
|
yamlPath)
|
||||||
|
|
||||||
cmd.Stdout = proc.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = proc.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
@ -246,27 +240,27 @@ func findBuiltImage(basePath string) string {
|
||||||
extensions := []string{".iso", "-bios.iso", ".qcow2", ".raw", ".vmdk"}
|
extensions := []string{".iso", "-bios.iso", ".qcow2", ".raw", ".vmdk"}
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
path := core.Concat(basePath, ext)
|
path := basePath + ext
|
||||||
if io.Local.IsFile(path) {
|
if _, err := os.Stat(path); err == nil {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check directory for any image file
|
// Check directory for any image file
|
||||||
dir := core.PathDir(basePath)
|
dir := filepath.Dir(basePath)
|
||||||
base := core.PathBase(basePath)
|
base := filepath.Base(basePath)
|
||||||
|
|
||||||
entries, err := io.Local.List(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
if core.HasPrefix(name, base) {
|
if strings.HasPrefix(name, base) {
|
||||||
for _, ext := range []string{".iso", ".qcow2", ".raw", ".vmdk"} {
|
for _, ext := range []string{".iso", ".qcow2", ".raw", ".vmdk"} {
|
||||||
if core.HasSuffix(name, ext) {
|
if strings.HasSuffix(name, ext) {
|
||||||
return coreutil.JoinPath(dir, name)
|
return filepath.Join(dir, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -278,7 +272,7 @@ func findBuiltImage(basePath string) string {
|
||||||
// lookupLinuxKit finds the linuxkit binary.
|
// lookupLinuxKit finds the linuxkit binary.
|
||||||
func lookupLinuxKit() (string, error) {
|
func lookupLinuxKit() (string, error) {
|
||||||
// Check PATH first
|
// Check PATH first
|
||||||
if path, err := proc.LookPath("linuxkit"); err == nil {
|
if path, err := exec.LookPath("linuxkit"); err == nil {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,7 +283,7 @@ func lookupLinuxKit() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
if io.Local.Exists(p) {
|
if _, err := os.Stat(p); err == nil {
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -299,36 +293,19 @@ func lookupLinuxKit() (string, error) {
|
||||||
|
|
||||||
// ParseVarFlags parses --var flags into a map.
|
// ParseVarFlags parses --var flags into a map.
|
||||||
// Format: --var KEY=VALUE or --var KEY="VALUE"
|
// Format: --var KEY=VALUE or --var KEY="VALUE"
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// vars := ParseVarFlags([]string{"SSH_KEY=abc", "PORT=2222"})
|
|
||||||
func ParseVarFlags(varFlags []string) map[string]string {
|
func ParseVarFlags(varFlags []string) map[string]string {
|
||||||
vars := make(map[string]string)
|
vars := make(map[string]string)
|
||||||
|
|
||||||
for _, v := range varFlags {
|
for _, v := range varFlags {
|
||||||
parts := core.SplitN(v, "=", 2)
|
parts := strings.SplitN(v, "=", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
key := core.Trim(parts[0])
|
key := strings.TrimSpace(parts[0])
|
||||||
value := core.Trim(parts[1])
|
value := strings.TrimSpace(parts[1])
|
||||||
// Remove surrounding quotes if present
|
// Remove surrounding quotes if present
|
||||||
value = stripWrappingQuotes(value)
|
value = strings.Trim(value, "\"'")
|
||||||
vars[key] = value
|
vars[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return vars
|
return vars
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripWrappingQuotes(value string) string {
|
|
||||||
if len(value) < 2 {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if core.HasPrefix(value, "\"") && core.HasSuffix(value, "\"") {
|
|
||||||
return core.TrimSuffix(core.TrimPrefix(value, "\""), "\"")
|
|
||||||
}
|
|
||||||
if core.HasPrefix(value, "'") && core.HasSuffix(value, "'") {
|
|
||||||
return core.TrimSuffix(core.TrimPrefix(value, "'"), "'")
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
package vm
|
package vm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"dappco.re/go/core/i18n"
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"forge.lthn.ai/core/go-i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -25,10 +25,6 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddVMCommands adds container-related commands under 'vm' to the CLI.
|
// AddVMCommands adds container-related commands under 'vm' to the CLI.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// AddVMCommands(root)
|
|
||||||
func AddVMCommands(root *cli.Command) {
|
func AddVMCommands(root *cli.Command) {
|
||||||
vmCmd := &cli.Command{
|
vmCmd := &cli.Command{
|
||||||
Use: "vm",
|
Use: "vm",
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,6 @@ type Manager interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateID creates a new unique container ID (8 hex characters).
|
// GenerateID creates a new unique container ID (8 hex characters).
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// id, err := GenerateID()
|
|
||||||
func GenerateID() (string, error) {
|
func GenerateID() (string, error) {
|
||||||
bytes := make([]byte, 4)
|
bytes := make([]byte, 4)
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClaudeOptions configures the Claude sandbox session.
|
// ClaudeOptions configures the Claude sandbox session.
|
||||||
|
|
@ -26,7 +27,7 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !running {
|
if !running {
|
||||||
core.Println("Dev environment not running, booting...")
|
fmt.Println("Dev environment not running, booting...")
|
||||||
if err := d.Boot(ctx, DefaultBootOptions()); err != nil {
|
if err := d.Boot(ctx, DefaultBootOptions()); err != nil {
|
||||||
return coreerr.E("DevOps.Claude", "failed to boot", err)
|
return coreerr.E("DevOps.Claude", "failed to boot", err)
|
||||||
}
|
}
|
||||||
|
|
@ -49,22 +50,20 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio
|
||||||
for _, auth := range authTypes {
|
for _, auth := range authTypes {
|
||||||
switch auth {
|
switch auth {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
if key := core.Env("ANTHROPIC_API_KEY"); key != "" {
|
if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" {
|
||||||
envVars = append(envVars, core.Concat("ANTHROPIC_API_KEY=", key))
|
envVars = append(envVars, "ANTHROPIC_API_KEY="+key)
|
||||||
}
|
}
|
||||||
case "git":
|
case "git":
|
||||||
// Forward git config
|
// Forward git config
|
||||||
name, _ := proc.NewCommand("git", "config", "user.name").Output()
|
name, _ := exec.Command("git", "config", "user.name").Output()
|
||||||
email, _ := proc.NewCommand("git", "config", "user.email").Output()
|
email, _ := exec.Command("git", "config", "user.email").Output()
|
||||||
if len(name) > 0 {
|
if len(name) > 0 {
|
||||||
trimmed := core.Trim(string(name))
|
envVars = append(envVars, "GIT_AUTHOR_NAME="+strings.TrimSpace(string(name)))
|
||||||
envVars = append(envVars, core.Concat("GIT_AUTHOR_NAME=", trimmed))
|
envVars = append(envVars, "GIT_COMMITTER_NAME="+strings.TrimSpace(string(name)))
|
||||||
envVars = append(envVars, core.Concat("GIT_COMMITTER_NAME=", trimmed))
|
|
||||||
}
|
}
|
||||||
if len(email) > 0 {
|
if len(email) > 0 {
|
||||||
trimmed := core.Trim(string(email))
|
envVars = append(envVars, "GIT_AUTHOR_EMAIL="+strings.TrimSpace(string(email)))
|
||||||
envVars = append(envVars, core.Concat("GIT_AUTHOR_EMAIL=", trimmed))
|
envVars = append(envVars, "GIT_COMMITTER_EMAIL="+strings.TrimSpace(string(email)))
|
||||||
envVars = append(envVars, core.Concat("GIT_COMMITTER_EMAIL=", trimmed))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +75,7 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio
|
||||||
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
||||||
"-o", "LogLevel=ERROR",
|
"-o", "LogLevel=ERROR",
|
||||||
"-A", // SSH agent forwarding
|
"-A", // SSH agent forwarding
|
||||||
"-p", core.Sprintf("%d", DefaultSSHPort),
|
"-p", fmt.Sprintf("%d", DefaultSSHPort),
|
||||||
}
|
}
|
||||||
|
|
||||||
args = append(args, "root@localhost")
|
args = append(args, "root@localhost")
|
||||||
|
|
@ -89,20 +88,23 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio
|
||||||
args = append(args, claudeCmd)
|
args = append(args, claudeCmd)
|
||||||
|
|
||||||
// Set environment for SSH
|
// Set environment for SSH
|
||||||
cmd := proc.NewCommandContext(ctx, "ssh", args...)
|
cmd := exec.CommandContext(ctx, "ssh", args...)
|
||||||
cmd.Stdin = proc.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = proc.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = proc.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
// Pass environment variables through SSH
|
// Pass environment variables through SSH
|
||||||
if len(envVars) > 0 {
|
for _, env := range envVars {
|
||||||
cmd.Env = append(proc.Environ(), envVars...)
|
parts := strings.SplitN(env, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
cmd.Env = append(os.Environ(), env)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Println("Starting Claude in sandboxed environment...")
|
fmt.Println("Starting Claude in sandboxed environment...")
|
||||||
core.Println("Project mounted at /app")
|
fmt.Println("Project mounted at /app")
|
||||||
core.Println(core.Concat("Auth forwarded: SSH agent", formatAuthList(opts)))
|
fmt.Println("Auth forwarded: SSH agent" + formatAuthList(opts))
|
||||||
core.Println()
|
fmt.Println()
|
||||||
|
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
@ -114,27 +116,27 @@ func formatAuthList(opts ClaudeOptions) string {
|
||||||
if len(opts.Auth) == 0 {
|
if len(opts.Auth) == 0 {
|
||||||
return ", gh, anthropic, git"
|
return ", gh, anthropic, git"
|
||||||
}
|
}
|
||||||
return core.Concat(", ", core.Join(", ", opts.Auth...))
|
return ", " + strings.Join(opts.Auth, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// CopyGHAuth copies GitHub CLI auth to the VM.
|
// CopyGHAuth copies GitHub CLI auth to the VM.
|
||||||
func (d *DevOps) CopyGHAuth(ctx context.Context) error {
|
func (d *DevOps) CopyGHAuth(ctx context.Context) error {
|
||||||
home := coreutil.HomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if home == "" {
|
if err != nil {
|
||||||
return coreerr.E("DevOps.CopyGHAuth", "home directory not available", nil)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ghConfigDir := coreutil.JoinPath(home, ".config", "gh")
|
ghConfigDir := filepath.Join(home, ".config", "gh")
|
||||||
if !io.Local.IsDir(ghConfigDir) {
|
if !io.Local.IsDir(ghConfigDir) {
|
||||||
return nil // No gh config to copy
|
return nil // No gh config to copy
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use scp to copy gh config
|
// Use scp to copy gh config
|
||||||
cmd := proc.NewCommandContext(ctx, "scp",
|
cmd := exec.CommandContext(ctx, "scp",
|
||||||
"-o", "StrictHostKeyChecking=yes",
|
"-o", "StrictHostKeyChecking=yes",
|
||||||
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
||||||
"-o", "LogLevel=ERROR",
|
"-o", "LogLevel=ERROR",
|
||||||
"-P", core.Sprintf("%d", DefaultSSHPort),
|
"-P", fmt.Sprintf("%d", DefaultSSHPort),
|
||||||
"-r", ghConfigDir,
|
"-r", ghConfigDir,
|
||||||
"root@localhost:/root/.config/",
|
"root@localhost:/root/.config/",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,14 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClaudeOptions_Default_Good(t *testing.T) {
|
func TestClaudeOptions_Default(t *testing.T) {
|
||||||
opts := ClaudeOptions{}
|
opts := ClaudeOptions{}
|
||||||
assert.False(t, opts.NoAuth)
|
assert.False(t, opts.NoAuth)
|
||||||
assert.Nil(t, opts.Auth)
|
assert.Nil(t, opts.Auth)
|
||||||
assert.Empty(t, opts.Model)
|
assert.Empty(t, opts.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClaudeOptions_Custom_Good(t *testing.T) {
|
func TestClaudeOptions_Custom(t *testing.T) {
|
||||||
opts := ClaudeOptions{
|
opts := ClaudeOptions{
|
||||||
NoAuth: true,
|
NoAuth: true,
|
||||||
Auth: []string{"gh", "anthropic"},
|
Auth: []string{"gh", "anthropic"},
|
||||||
|
|
@ -24,19 +24,19 @@ func TestClaudeOptions_Custom_Good(t *testing.T) {
|
||||||
assert.Equal(t, "opus", opts.Model)
|
assert.Equal(t, "opus", opts.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatAuthList_NoAuth_Good(t *testing.T) {
|
func TestFormatAuthList_Good_NoAuth(t *testing.T) {
|
||||||
opts := ClaudeOptions{NoAuth: true}
|
opts := ClaudeOptions{NoAuth: true}
|
||||||
result := formatAuthList(opts)
|
result := formatAuthList(opts)
|
||||||
assert.Equal(t, " (none)", result)
|
assert.Equal(t, " (none)", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatAuthList_Default_Good(t *testing.T) {
|
func TestFormatAuthList_Good_Default(t *testing.T) {
|
||||||
opts := ClaudeOptions{}
|
opts := ClaudeOptions{}
|
||||||
result := formatAuthList(opts)
|
result := formatAuthList(opts)
|
||||||
assert.Equal(t, ", gh, anthropic, git", result)
|
assert.Equal(t, ", gh, anthropic, git", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatAuthList_CustomAuth_Good(t *testing.T) {
|
func TestFormatAuthList_Good_CustomAuth(t *testing.T) {
|
||||||
opts := ClaudeOptions{
|
opts := ClaudeOptions{
|
||||||
Auth: []string{"gh"},
|
Auth: []string{"gh"},
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ func TestFormatAuthList_CustomAuth_Good(t *testing.T) {
|
||||||
assert.Equal(t, ", gh", result)
|
assert.Equal(t, ", gh", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatAuthList_MultipleAuth_Good(t *testing.T) {
|
func TestFormatAuthList_Good_MultipleAuth(t *testing.T) {
|
||||||
opts := ClaudeOptions{
|
opts := ClaudeOptions{
|
||||||
Auth: []string{"gh", "ssh", "git"},
|
Auth: []string{"gh", "ssh", "git"},
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ func TestFormatAuthList_MultipleAuth_Good(t *testing.T) {
|
||||||
assert.Equal(t, ", gh, ssh, git", result)
|
assert.Equal(t, ", gh, ssh, git", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatAuthList_EmptyAuth_Good(t *testing.T) {
|
func TestFormatAuthList_Good_EmptyAuth(t *testing.T) {
|
||||||
opts := ClaudeOptions{
|
opts := ClaudeOptions{
|
||||||
Auth: []string{},
|
Auth: []string{},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
package devenv
|
package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"dappco.re/go/core/io"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"forge.lthn.ai/core/config"
|
"forge.lthn.ai/core/config"
|
||||||
|
"forge.lthn.ai/core/go-io"
|
||||||
core "dappco.re/go/core"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds global devops configuration from ~/.core/config.yaml.
|
// Config holds global devops configuration from ~/.core/config.yaml.
|
||||||
|
|
@ -39,10 +38,6 @@ type CDNConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns sensible defaults.
|
// DefaultConfig returns sensible defaults.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// cfg := DefaultConfig()
|
|
||||||
func DefaultConfig() *Config {
|
func DefaultConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
|
|
@ -59,24 +54,16 @@ func DefaultConfig() *Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigPath returns the path to the config file.
|
// ConfigPath returns the path to the config file.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// path, err := ConfigPath()
|
|
||||||
func ConfigPath() (string, error) {
|
func ConfigPath() (string, error) {
|
||||||
home := coreutil.HomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if home == "" {
|
if err != nil {
|
||||||
return "", core.E("ConfigPath", "home directory not available", nil)
|
return "", err
|
||||||
}
|
}
|
||||||
return coreutil.JoinPath(home, ".core", "config.yaml"), nil
|
return filepath.Join(home, ".core", "config.yaml"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig loads configuration from ~/.core/config.yaml using the provided medium.
|
// LoadConfig loads configuration from ~/.core/config.yaml using the provided medium.
|
||||||
// Returns default config if file doesn't exist.
|
// Returns default config if file doesn't exist.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// cfg, err := LoadConfig(io.Local)
|
|
||||||
func LoadConfig(m io.Medium) (*Config, error) {
|
func LoadConfig(m io.Medium) (*Config, error) {
|
||||||
configPath, err := ConfigPath()
|
configPath, err := ConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,35 @@
|
||||||
package devenv
|
package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"syscall"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfig_DefaultConfig_Good(t *testing.T) {
|
func TestDefaultConfig(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
assert.Equal(t, 1, cfg.Version)
|
assert.Equal(t, 1, cfg.Version)
|
||||||
assert.Equal(t, "auto", cfg.Images.Source)
|
assert.Equal(t, "auto", cfg.Images.Source)
|
||||||
assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo)
|
assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_ConfigPath_Good(t *testing.T) {
|
func TestConfigPath(t *testing.T) {
|
||||||
path, err := ConfigPath()
|
path, err := ConfigPath()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Contains(t, path, ".core/config.yaml")
|
assert.Contains(t, path, ".core/config.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_LoadConfig_Good(t *testing.T) {
|
func TestLoadConfig_Good(t *testing.T) {
|
||||||
t.Run("returns default if not exists", func(t *testing.T) {
|
t.Run("returns default if not exists", func(t *testing.T) {
|
||||||
// Mock HOME to a temp dir
|
// Mock HOME to a temp dir
|
||||||
tempHome := t.TempDir()
|
tempHome := t.TempDir()
|
||||||
|
origHome := os.Getenv("HOME")
|
||||||
t.Setenv("HOME", tempHome)
|
t.Setenv("HOME", tempHome)
|
||||||
|
defer func() { _ = os.Setenv("HOME", origHome) }()
|
||||||
|
|
||||||
cfg, err := LoadConfig(io.Local)
|
cfg, err := LoadConfig(io.Local)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -38,8 +40,8 @@ func TestConfig_LoadConfig_Good(t *testing.T) {
|
||||||
tempHome := t.TempDir()
|
tempHome := t.TempDir()
|
||||||
t.Setenv("HOME", tempHome)
|
t.Setenv("HOME", tempHome)
|
||||||
|
|
||||||
coreDir := coreutil.JoinPath(tempHome, ".core")
|
coreDir := filepath.Join(tempHome, ".core")
|
||||||
err := io.Local.EnsureDir(coreDir)
|
err := os.MkdirAll(coreDir, 0755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
configData := `
|
configData := `
|
||||||
|
|
@ -49,7 +51,7 @@ images:
|
||||||
cdn:
|
cdn:
|
||||||
url: https://cdn.example.com
|
url: https://cdn.example.com
|
||||||
`
|
`
|
||||||
err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), configData)
|
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg, err := LoadConfig(io.Local)
|
cfg, err := LoadConfig(io.Local)
|
||||||
|
|
@ -60,16 +62,16 @@ images:
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_LoadConfig_Bad(t *testing.T) {
|
func TestLoadConfig_Bad(t *testing.T) {
|
||||||
t.Run("invalid yaml", func(t *testing.T) {
|
t.Run("invalid yaml", func(t *testing.T) {
|
||||||
tempHome := t.TempDir()
|
tempHome := t.TempDir()
|
||||||
t.Setenv("HOME", tempHome)
|
t.Setenv("HOME", tempHome)
|
||||||
|
|
||||||
coreDir := coreutil.JoinPath(tempHome, ".core")
|
coreDir := filepath.Join(tempHome, ".core")
|
||||||
err := io.Local.EnsureDir(coreDir)
|
err := os.MkdirAll(coreDir, 0755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), "invalid: yaml: :")
|
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte("invalid: yaml: :"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = LoadConfig(io.Local)
|
_, err = LoadConfig(io.Local)
|
||||||
|
|
@ -77,7 +79,7 @@ func TestConfig_LoadConfig_Bad(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_Struct_Good(t *testing.T) {
|
func TestConfig_Struct(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Version: 2,
|
Version: 2,
|
||||||
Images: ImagesConfig{
|
Images: ImagesConfig{
|
||||||
|
|
@ -100,7 +102,7 @@ func TestConfig_Struct_Good(t *testing.T) {
|
||||||
assert.Equal(t, "https://cdn.example.com", cfg.Images.CDN.URL)
|
assert.Equal(t, "https://cdn.example.com", cfg.Images.CDN.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultConfig_Complete_Good(t *testing.T) {
|
func TestDefaultConfig_Complete(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
assert.Equal(t, 1, cfg.Version)
|
assert.Equal(t, 1, cfg.Version)
|
||||||
assert.Equal(t, "auto", cfg.Images.Source)
|
assert.Equal(t, "auto", cfg.Images.Source)
|
||||||
|
|
@ -109,12 +111,12 @@ func TestDefaultConfig_Complete_Good(t *testing.T) {
|
||||||
assert.Empty(t, cfg.Images.CDN.URL)
|
assert.Empty(t, cfg.Images.CDN.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfig_PartialConfig_Good(t *testing.T) {
|
func TestLoadConfig_Good_PartialConfig(t *testing.T) {
|
||||||
tempHome := t.TempDir()
|
tempHome := t.TempDir()
|
||||||
t.Setenv("HOME", tempHome)
|
t.Setenv("HOME", tempHome)
|
||||||
|
|
||||||
coreDir := coreutil.JoinPath(tempHome, ".core")
|
coreDir := filepath.Join(tempHome, ".core")
|
||||||
err := io.Local.EnsureDir(coreDir)
|
err := os.MkdirAll(coreDir, 0755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Config only specifies source, should merge with defaults
|
// Config only specifies source, should merge with defaults
|
||||||
|
|
@ -123,7 +125,7 @@ version: 1
|
||||||
images:
|
images:
|
||||||
source: github
|
source: github
|
||||||
`
|
`
|
||||||
err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), configData)
|
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg, err := LoadConfig(io.Local)
|
cfg, err := LoadConfig(io.Local)
|
||||||
|
|
@ -134,7 +136,7 @@ images:
|
||||||
assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo)
|
assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfig_AllSourceTypes_Good(t *testing.T) {
|
func TestLoadConfig_Good_AllSourceTypes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
config string
|
config string
|
||||||
|
|
@ -189,11 +191,11 @@ images:
|
||||||
tempHome := t.TempDir()
|
tempHome := t.TempDir()
|
||||||
t.Setenv("HOME", tempHome)
|
t.Setenv("HOME", tempHome)
|
||||||
|
|
||||||
coreDir := coreutil.JoinPath(tempHome, ".core")
|
coreDir := filepath.Join(tempHome, ".core")
|
||||||
err := io.Local.EnsureDir(coreDir)
|
err := os.MkdirAll(coreDir, 0755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), tt.config)
|
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(tt.config), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg, err := LoadConfig(io.Local)
|
cfg, err := LoadConfig(io.Local)
|
||||||
|
|
@ -203,7 +205,7 @@ images:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImagesConfig_Struct_Good(t *testing.T) {
|
func TestImagesConfig_Struct(t *testing.T) {
|
||||||
ic := ImagesConfig{
|
ic := ImagesConfig{
|
||||||
Source: "auto",
|
Source: "auto",
|
||||||
GitHub: GitHubConfig{Repo: "test/repo"},
|
GitHub: GitHubConfig{Repo: "test/repo"},
|
||||||
|
|
@ -212,42 +214,42 @@ func TestImagesConfig_Struct_Good(t *testing.T) {
|
||||||
assert.Equal(t, "test/repo", ic.GitHub.Repo)
|
assert.Equal(t, "test/repo", ic.GitHub.Repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitHubConfig_Struct_Good(t *testing.T) {
|
func TestGitHubConfig_Struct(t *testing.T) {
|
||||||
gc := GitHubConfig{Repo: "owner/repo"}
|
gc := GitHubConfig{Repo: "owner/repo"}
|
||||||
assert.Equal(t, "owner/repo", gc.Repo)
|
assert.Equal(t, "owner/repo", gc.Repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistryConfig_Struct_Good(t *testing.T) {
|
func TestRegistryConfig_Struct(t *testing.T) {
|
||||||
rc := RegistryConfig{Image: "ghcr.io/owner/image:latest"}
|
rc := RegistryConfig{Image: "ghcr.io/owner/image:latest"}
|
||||||
assert.Equal(t, "ghcr.io/owner/image:latest", rc.Image)
|
assert.Equal(t, "ghcr.io/owner/image:latest", rc.Image)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNConfig_Struct_Good(t *testing.T) {
|
func TestCDNConfig_Struct(t *testing.T) {
|
||||||
cc := CDNConfig{URL: "https://cdn.example.com/images"}
|
cc := CDNConfig{URL: "https://cdn.example.com/images"}
|
||||||
assert.Equal(t, "https://cdn.example.com/images", cc.URL)
|
assert.Equal(t, "https://cdn.example.com/images", cc.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfig_UnreadableFile_Bad(t *testing.T) {
|
func TestLoadConfig_Bad_UnreadableFile(t *testing.T) {
|
||||||
// This test is platform-specific and may not work on all systems
|
// This test is platform-specific and may not work on all systems
|
||||||
// Skip if we can't test file permissions properly
|
// Skip if we can't test file permissions properly
|
||||||
if syscall.Getuid() == 0 {
|
if os.Getuid() == 0 {
|
||||||
t.Skip("Skipping permission test when running as root")
|
t.Skip("Skipping permission test when running as root")
|
||||||
}
|
}
|
||||||
|
|
||||||
tempHome := t.TempDir()
|
tempHome := t.TempDir()
|
||||||
t.Setenv("HOME", tempHome)
|
t.Setenv("HOME", tempHome)
|
||||||
|
|
||||||
coreDir := coreutil.JoinPath(tempHome, ".core")
|
coreDir := filepath.Join(tempHome, ".core")
|
||||||
err := io.Local.EnsureDir(coreDir)
|
err := os.MkdirAll(coreDir, 0755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
configPath := coreutil.JoinPath(coreDir, "config.yaml")
|
configPath := filepath.Join(coreDir, "config.yaml")
|
||||||
err = io.Local.WriteMode(configPath, "version: 1", 0000)
|
err = os.WriteFile(configPath, []byte("version: 1"), 0000)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = LoadConfig(io.Local)
|
_, err = LoadConfig(io.Local)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
// Restore permissions so cleanup works
|
// Restore permissions so cleanup works
|
||||||
_ = syscall.Chmod(configPath, 0644)
|
_ = os.Chmod(configPath, 0644)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,15 @@ package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-container"
|
||||||
"dappco.re/go/core/container"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -28,10 +28,6 @@ type DevOps struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new DevOps instance using the provided medium.
|
// New creates a new DevOps instance using the provided medium.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// dev, err := New(io.Local)
|
|
||||||
func New(m io.Medium) (*DevOps, error) {
|
func New(m io.Medium) (*DevOps, error) {
|
||||||
cfg, err := LoadConfig(m)
|
cfg, err := LoadConfig(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -57,41 +53,29 @@ func New(m io.Medium) (*DevOps, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageName returns the platform-specific image name.
|
// ImageName returns the platform-specific image name.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// name := ImageName()
|
|
||||||
func ImageName() string {
|
func ImageName() string {
|
||||||
return core.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH)
|
return fmt.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImagesDir returns the path to the images directory.
|
// ImagesDir returns the path to the images directory.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// dir, err := ImagesDir()
|
|
||||||
func ImagesDir() (string, error) {
|
func ImagesDir() (string, error) {
|
||||||
if dir := core.Env("CORE_IMAGES_DIR"); dir != "" {
|
if dir := os.Getenv("CORE_IMAGES_DIR"); dir != "" {
|
||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
home := coreutil.HomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if home == "" {
|
if err != nil {
|
||||||
return "", core.E("ImagesDir", "home directory not available", nil)
|
return "", err
|
||||||
}
|
}
|
||||||
return coreutil.JoinPath(home, ".core", "images"), nil
|
return filepath.Join(home, ".core", "images"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImagePath returns the full path to the platform-specific image.
|
// ImagePath returns the full path to the platform-specific image.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// path, err := ImagePath()
|
|
||||||
func ImagePath() (string, error) {
|
func ImagePath() (string, error) {
|
||||||
dir, err := ImagesDir()
|
dir, err := ImagesDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return coreutil.JoinPath(dir, ImageName()), nil
|
return filepath.Join(dir, ImageName()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsInstalled checks if the dev image is installed.
|
// IsInstalled checks if the dev image is installed.
|
||||||
|
|
@ -122,10 +106,6 @@ type BootOptions struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultBootOptions returns sensible defaults.
|
// DefaultBootOptions returns sensible defaults.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// opts := DefaultBootOptions()
|
|
||||||
func DefaultBootOptions() BootOptions {
|
func DefaultBootOptions() BootOptions {
|
||||||
return BootOptions{
|
return BootOptions{
|
||||||
Memory: 4096,
|
Memory: 4096,
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,20 @@ package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"syscall"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-container"
|
||||||
"dappco.re/go/core/container"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
"dappco.re/go/core/io"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newManagedTempDir(t *testing.T, prefix string) string {
|
func TestImageName(t *testing.T) {
|
||||||
t.Helper()
|
|
||||||
dir, err := coreutil.MkdirTemp(prefix)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = io.Local.DeleteAll(dir) })
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDevOps_ImageName_Good(t *testing.T) {
|
|
||||||
name := ImageName()
|
name := ImageName()
|
||||||
assert.Contains(t, name, "core-devops-")
|
assert.Contains(t, name, "core-devops-")
|
||||||
assert.Contains(t, name, runtime.GOOS)
|
assert.Contains(t, name, runtime.GOOS)
|
||||||
|
|
@ -32,9 +23,12 @@ func TestDevOps_ImageName_Good(t *testing.T) {
|
||||||
assert.True(t, (name[len(name)-6:] == ".qcow2"))
|
assert.True(t, (name[len(name)-6:] == ".qcow2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_ImagesDir_Good(t *testing.T) {
|
func TestImagesDir(t *testing.T) {
|
||||||
t.Run("default directory", func(t *testing.T) {
|
t.Run("default directory", func(t *testing.T) {
|
||||||
t.Setenv("CORE_IMAGES_DIR", "")
|
// Unset env if it exists
|
||||||
|
orig := os.Getenv("CORE_IMAGES_DIR")
|
||||||
|
_ = os.Unsetenv("CORE_IMAGES_DIR")
|
||||||
|
defer func() { _ = os.Setenv("CORE_IMAGES_DIR", orig) }()
|
||||||
|
|
||||||
dir, err := ImagesDir()
|
dir, err := ImagesDir()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -51,17 +45,17 @@ func TestDevOps_ImagesDir_Good(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_ImagePath_Good(t *testing.T) {
|
func TestImagePath(t *testing.T) {
|
||||||
customDir := "/tmp/images"
|
customDir := "/tmp/images"
|
||||||
t.Setenv("CORE_IMAGES_DIR", customDir)
|
t.Setenv("CORE_IMAGES_DIR", customDir)
|
||||||
|
|
||||||
path, err := ImagePath()
|
path, err := ImagePath()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
expected := coreutil.JoinPath(customDir, ImageName())
|
expected := filepath.Join(customDir, ImageName())
|
||||||
assert.Equal(t, expected, path)
|
assert.Equal(t, expected, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_DefaultBootOptions_Good(t *testing.T) {
|
func TestDefaultBootOptions(t *testing.T) {
|
||||||
opts := DefaultBootOptions()
|
opts := DefaultBootOptions()
|
||||||
assert.Equal(t, 4096, opts.Memory)
|
assert.Equal(t, 4096, opts.Memory)
|
||||||
assert.Equal(t, 2, opts.CPUs)
|
assert.Equal(t, 2, opts.CPUs)
|
||||||
|
|
@ -69,7 +63,7 @@ func TestDevOps_DefaultBootOptions_Good(t *testing.T) {
|
||||||
assert.False(t, opts.Fresh)
|
assert.False(t, opts.Fresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_IsInstalled_Bad(t *testing.T) {
|
func TestIsInstalled_Bad(t *testing.T) {
|
||||||
t.Run("returns false for non-existent image", func(t *testing.T) {
|
t.Run("returns false for non-existent image", func(t *testing.T) {
|
||||||
// Point to a temp directory that is empty
|
// Point to a temp directory that is empty
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
@ -81,14 +75,14 @@ func TestDevOps_IsInstalled_Bad(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_IsInstalled_Good(t *testing.T) {
|
func TestIsInstalled_Good(t *testing.T) {
|
||||||
t.Run("returns true when image exists", func(t *testing.T) {
|
t.Run("returns true when image exists", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
// Create the image file
|
// Create the image file
|
||||||
imagePath := coreutil.JoinPath(tempDir, ImageName())
|
imagePath := filepath.Join(tempDir, ImageName())
|
||||||
err := io.Local.Write(imagePath, "fake image data")
|
err := os.WriteFile(imagePath, []byte("fake image data"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := &DevOps{medium: io.Local}
|
d := &DevOps{medium: io.Local}
|
||||||
|
|
@ -100,8 +94,8 @@ type mockHypervisor struct{}
|
||||||
|
|
||||||
func (m *mockHypervisor) Name() string { return "mock" }
|
func (m *mockHypervisor) Name() string { return "mock" }
|
||||||
func (m *mockHypervisor) Available() bool { return true }
|
func (m *mockHypervisor) Available() bool { return true }
|
||||||
func (m *mockHypervisor) BuildCommand(ctx context.Context, image string, opts *container.HypervisorOptions) (*proc.Command, error) {
|
func (m *mockHypervisor) BuildCommand(ctx context.Context, image string, opts *container.HypervisorOptions) (*exec.Cmd, error) {
|
||||||
return proc.NewCommand("true"), nil
|
return exec.Command("true"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Status_Good(t *testing.T) {
|
func TestDevOps_Status_Good(t *testing.T) {
|
||||||
|
|
@ -113,7 +107,7 @@ func TestDevOps_Status_Good(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Setup mock container manager
|
// Setup mock container manager
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -128,7 +122,7 @@ func TestDevOps_Status_Good(t *testing.T) {
|
||||||
ID: "test-id",
|
ID: "test-id",
|
||||||
Name: "core-dev",
|
Name: "core-dev",
|
||||||
Status: container.StatusRunning,
|
Status: container.StatusRunning,
|
||||||
PID: syscall.Getpid(), // Use our own PID so isProcessRunning returns true
|
PID: os.Getpid(), // Use our own PID so isProcessRunning returns true
|
||||||
StartedAt: time.Now().Add(-time.Hour),
|
StartedAt: time.Now().Add(-time.Hour),
|
||||||
Memory: 2048,
|
Memory: 2048,
|
||||||
CPUs: 4,
|
CPUs: 4,
|
||||||
|
|
@ -145,7 +139,7 @@ func TestDevOps_Status_Good(t *testing.T) {
|
||||||
assert.Equal(t, 4, status.CPUs)
|
assert.Equal(t, 4, status.CPUs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Status_NotInstalled_Good(t *testing.T) {
|
func TestDevOps_Status_Good_NotInstalled(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
@ -153,7 +147,7 @@ func TestDevOps_Status_NotInstalled_Good(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -171,20 +165,20 @@ func TestDevOps_Status_NotInstalled_Good(t *testing.T) {
|
||||||
assert.Equal(t, 2222, status.SSHPort)
|
assert.Equal(t, 2222, status.SSHPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Status_NoContainer_Good(t *testing.T) {
|
func TestDevOps_Status_Good_NoContainer(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
// Create fake image to mark as installed
|
// Create fake image to mark as installed
|
||||||
imagePath := coreutil.JoinPath(tempDir, ImageName())
|
imagePath := filepath.Join(tempDir, ImageName())
|
||||||
err := io.Local.Write(imagePath, "fake")
|
err := os.WriteFile(imagePath, []byte("fake"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -210,7 +204,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -224,7 +218,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) {
|
||||||
ID: "test-id",
|
ID: "test-id",
|
||||||
Name: "core-dev",
|
Name: "core-dev",
|
||||||
Status: container.StatusRunning,
|
Status: container.StatusRunning,
|
||||||
PID: syscall.Getpid(),
|
PID: os.Getpid(),
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
err = state.Add(c)
|
err = state.Add(c)
|
||||||
|
|
@ -235,7 +229,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) {
|
||||||
assert.True(t, running)
|
assert.True(t, running)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_IsRunning_NotRunning_Bad(t *testing.T) {
|
func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
@ -243,7 +237,7 @@ func TestDevOps_IsRunning_NotRunning_Bad(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -258,7 +252,7 @@ func TestDevOps_IsRunning_NotRunning_Bad(t *testing.T) {
|
||||||
assert.False(t, running)
|
assert.False(t, running)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_IsRunning_ContainerStopped_Bad(t *testing.T) {
|
func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
@ -266,7 +260,7 @@ func TestDevOps_IsRunning_ContainerStopped_Bad(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -299,7 +293,7 @@ func TestDevOps_findContainer_Good(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -313,7 +307,7 @@ func TestDevOps_findContainer_Good(t *testing.T) {
|
||||||
ID: "test-id",
|
ID: "test-id",
|
||||||
Name: "my-container",
|
Name: "my-container",
|
||||||
Status: container.StatusRunning,
|
Status: container.StatusRunning,
|
||||||
PID: syscall.Getpid(),
|
PID: os.Getpid(),
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
err = state.Add(c)
|
err = state.Add(c)
|
||||||
|
|
@ -326,7 +320,7 @@ func TestDevOps_findContainer_Good(t *testing.T) {
|
||||||
assert.Equal(t, "my-container", found.Name)
|
assert.Equal(t, "my-container", found.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_findContainer_NotFound_Bad(t *testing.T) {
|
func TestDevOps_findContainer_Bad_NotFound(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
@ -334,7 +328,7 @@ func TestDevOps_findContainer_NotFound_Bad(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -349,7 +343,7 @@ func TestDevOps_findContainer_NotFound_Bad(t *testing.T) {
|
||||||
assert.Nil(t, found)
|
assert.Nil(t, found)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Stop_NotFound_Bad(t *testing.T) {
|
func TestDevOps_Stop_Bad_NotFound(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
@ -357,7 +351,7 @@ func TestDevOps_Stop_NotFound_Bad(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -372,7 +366,7 @@ func TestDevOps_Stop_NotFound_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBootOptions_Custom_Good(t *testing.T) {
|
func TestBootOptions_Custom(t *testing.T) {
|
||||||
opts := BootOptions{
|
opts := BootOptions{
|
||||||
Memory: 8192,
|
Memory: 8192,
|
||||||
CPUs: 4,
|
CPUs: 4,
|
||||||
|
|
@ -385,7 +379,7 @@ func TestBootOptions_Custom_Good(t *testing.T) {
|
||||||
assert.True(t, opts.Fresh)
|
assert.True(t, opts.Fresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevStatus_Struct_Good(t *testing.T) {
|
func TestDevStatus_Struct(t *testing.T) {
|
||||||
status := DevStatus{
|
status := DevStatus{
|
||||||
Installed: true,
|
Installed: true,
|
||||||
Running: true,
|
Running: true,
|
||||||
|
|
@ -406,7 +400,7 @@ func TestDevStatus_Struct_Good(t *testing.T) {
|
||||||
assert.Equal(t, time.Hour, status.Uptime)
|
assert.Equal(t, time.Hour, status.Uptime)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Boot_NotInstalled_Bad(t *testing.T) {
|
func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
@ -414,7 +408,7 @@ func TestDevOps_Boot_NotInstalled_Bad(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -429,20 +423,20 @@ func TestDevOps_Boot_NotInstalled_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "not installed")
|
assert.Contains(t, err.Error(), "not installed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Boot_AlreadyRunning_Bad(t *testing.T) {
|
func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
// Create fake image
|
// Create fake image
|
||||||
imagePath := coreutil.JoinPath(tempDir, ImageName())
|
imagePath := filepath.Join(tempDir, ImageName())
|
||||||
err := io.Local.Write(imagePath, "fake")
|
err := os.WriteFile(imagePath, []byte("fake"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -457,7 +451,7 @@ func TestDevOps_Boot_AlreadyRunning_Bad(t *testing.T) {
|
||||||
ID: "test-id",
|
ID: "test-id",
|
||||||
Name: "core-dev",
|
Name: "core-dev",
|
||||||
Status: container.StatusRunning,
|
Status: container.StatusRunning,
|
||||||
PID: syscall.Getpid(),
|
PID: os.Getpid(),
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
err = state.Add(c)
|
err = state.Add(c)
|
||||||
|
|
@ -468,13 +462,13 @@ func TestDevOps_Boot_AlreadyRunning_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "already running")
|
assert.Contains(t, err.Error(), "already running")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Status_WithImageVersion_Good(t *testing.T) {
|
func TestDevOps_Status_Good_WithImageVersion(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
// Create fake image
|
// Create fake image
|
||||||
imagePath := coreutil.JoinPath(tempDir, ImageName())
|
imagePath := filepath.Join(tempDir, ImageName())
|
||||||
err := io.Local.Write(imagePath, "fake")
|
err := os.WriteFile(imagePath, []byte("fake"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
@ -487,7 +481,7 @@ func TestDevOps_Status_WithImageVersion_Good(t *testing.T) {
|
||||||
Source: "test",
|
Source: "test",
|
||||||
}
|
}
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -504,7 +498,7 @@ func TestDevOps_Status_WithImageVersion_Good(t *testing.T) {
|
||||||
assert.Equal(t, "v1.2.3", status.ImageVersion)
|
assert.Equal(t, "v1.2.3", status.ImageVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_findContainer_MultipleContainers_Good(t *testing.T) {
|
func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
@ -512,7 +506,7 @@ func TestDevOps_findContainer_MultipleContainers_Good(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -527,14 +521,14 @@ func TestDevOps_findContainer_MultipleContainers_Good(t *testing.T) {
|
||||||
ID: "id-1",
|
ID: "id-1",
|
||||||
Name: "container-1",
|
Name: "container-1",
|
||||||
Status: container.StatusRunning,
|
Status: container.StatusRunning,
|
||||||
PID: syscall.Getpid(),
|
PID: os.Getpid(),
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
c2 := &container.Container{
|
c2 := &container.Container{
|
||||||
ID: "id-2",
|
ID: "id-2",
|
||||||
Name: "container-2",
|
Name: "container-2",
|
||||||
Status: container.StatusRunning,
|
Status: container.StatusRunning,
|
||||||
PID: syscall.Getpid(),
|
PID: os.Getpid(),
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
err = state.Add(c1)
|
err = state.Add(c1)
|
||||||
|
|
@ -549,7 +543,7 @@ func TestDevOps_findContainer_MultipleContainers_Good(t *testing.T) {
|
||||||
assert.Equal(t, "id-2", found.ID)
|
assert.Equal(t, "id-2", found.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Status_ContainerWithUptime_Good(t *testing.T) {
|
func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
@ -557,7 +551,7 @@ func TestDevOps_Status_ContainerWithUptime_Good(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -572,7 +566,7 @@ func TestDevOps_Status_ContainerWithUptime_Good(t *testing.T) {
|
||||||
ID: "test-id",
|
ID: "test-id",
|
||||||
Name: "core-dev",
|
Name: "core-dev",
|
||||||
Status: container.StatusRunning,
|
Status: container.StatusRunning,
|
||||||
PID: syscall.Getpid(),
|
PID: os.Getpid(),
|
||||||
StartedAt: startTime,
|
StartedAt: startTime,
|
||||||
Memory: 4096,
|
Memory: 4096,
|
||||||
CPUs: 2,
|
CPUs: 2,
|
||||||
|
|
@ -586,7 +580,7 @@ func TestDevOps_Status_ContainerWithUptime_Good(t *testing.T) {
|
||||||
assert.GreaterOrEqual(t, status.Uptime.Hours(), float64(1))
|
assert.GreaterOrEqual(t, status.Uptime.Hours(), float64(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_IsRunning_DifferentContainerName_Bad(t *testing.T) {
|
func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
@ -594,7 +588,7 @@ func TestDevOps_IsRunning_DifferentContainerName_Bad(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -609,7 +603,7 @@ func TestDevOps_IsRunning_DifferentContainerName_Bad(t *testing.T) {
|
||||||
ID: "test-id",
|
ID: "test-id",
|
||||||
Name: "other-container",
|
Name: "other-container",
|
||||||
Status: container.StatusRunning,
|
Status: container.StatusRunning,
|
||||||
PID: syscall.Getpid(),
|
PID: os.Getpid(),
|
||||||
StartedAt: time.Now(),
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
err = state.Add(c)
|
err = state.Add(c)
|
||||||
|
|
@ -621,21 +615,23 @@ func TestDevOps_IsRunning_DifferentContainerName_Bad(t *testing.T) {
|
||||||
assert.False(t, running)
|
assert.False(t, running)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Boot_FreshFlag_Good(t *testing.T) {
|
func TestDevOps_Boot_Good_FreshFlag(t *testing.T) {
|
||||||
t.Setenv("CORE_SKIP_SSH_SCAN", "true")
|
t.Setenv("CORE_SKIP_SSH_SCAN", "true")
|
||||||
tempDir := newManagedTempDir(t, "devops-test-")
|
tempDir, err := os.MkdirTemp("", "devops-test-*")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
// Create fake image
|
// Create fake image
|
||||||
imagePath := coreutil.JoinPath(tempDir, ImageName())
|
imagePath := filepath.Join(tempDir, ImageName())
|
||||||
err := io.Local.Write(imagePath, "fake")
|
err = os.WriteFile(imagePath, []byte("fake"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -669,7 +665,7 @@ func TestDevOps_Boot_FreshFlag_Good(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Stop_ContainerNotRunning_Bad(t *testing.T) {
|
func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
@ -677,7 +673,7 @@ func TestDevOps_Stop_ContainerNotRunning_Bad(t *testing.T) {
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -704,21 +700,23 @@ func TestDevOps_Stop_ContainerNotRunning_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "not running")
|
assert.Contains(t, err.Error(), "not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Boot_FreshWithNoExisting_Good(t *testing.T) {
|
func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) {
|
||||||
t.Setenv("CORE_SKIP_SSH_SCAN", "true")
|
t.Setenv("CORE_SKIP_SSH_SCAN", "true")
|
||||||
tempDir := newManagedTempDir(t, "devops-boot-fresh-")
|
tempDir, err := os.MkdirTemp("", "devops-boot-fresh-*")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
// Create fake image
|
// Create fake image
|
||||||
imagePath := coreutil.JoinPath(tempDir, ImageName())
|
imagePath := filepath.Join(tempDir, ImageName())
|
||||||
err := io.Local.Write(imagePath, "fake")
|
err = os.WriteFile(imagePath, []byte("fake"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -740,16 +738,16 @@ func TestDevOps_Boot_FreshWithNoExisting_Good(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageName_Format_Good(t *testing.T) {
|
func TestImageName_Format(t *testing.T) {
|
||||||
name := ImageName()
|
name := ImageName()
|
||||||
// Check format: core-devops-{os}-{arch}.qcow2
|
// Check format: core-devops-{os}-{arch}.qcow2
|
||||||
assert.Contains(t, name, "core-devops-")
|
assert.Contains(t, name, "core-devops-")
|
||||||
assert.Contains(t, name, runtime.GOOS)
|
assert.Contains(t, name, runtime.GOOS)
|
||||||
assert.Contains(t, name, runtime.GOARCH)
|
assert.Contains(t, name, runtime.GOARCH)
|
||||||
assert.True(t, core.PathExt(name) == ".qcow2")
|
assert.True(t, filepath.Ext(name) == ".qcow2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Install_Delegates_Good(t *testing.T) {
|
func TestDevOps_Install_Delegates(t *testing.T) {
|
||||||
// This test verifies the Install method delegates to ImageManager
|
// This test verifies the Install method delegates to ImageManager
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
@ -767,7 +765,7 @@ func TestDevOps_Install_Delegates_Good(t *testing.T) {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_CheckUpdate_Delegates_Good(t *testing.T) {
|
func TestDevOps_CheckUpdate_Delegates(t *testing.T) {
|
||||||
// This test verifies the CheckUpdate method delegates to ImageManager
|
// This test verifies the CheckUpdate method delegates to ImageManager
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
@ -785,21 +783,23 @@ func TestDevOps_CheckUpdate_Delegates_Good(t *testing.T) {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Boot_Success_Good(t *testing.T) {
|
func TestDevOps_Boot_Good_Success(t *testing.T) {
|
||||||
t.Setenv("CORE_SKIP_SSH_SCAN", "true")
|
t.Setenv("CORE_SKIP_SSH_SCAN", "true")
|
||||||
tempDir := newManagedTempDir(t, "devops-boot-success-")
|
tempDir, err := os.MkdirTemp("", "devops-boot-success-*")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
// Create fake image
|
// Create fake image
|
||||||
imagePath := coreutil.JoinPath(tempDir, ImageName())
|
imagePath := filepath.Join(tempDir, ImageName())
|
||||||
err := io.Local.Write(imagePath, "fake")
|
err = os.WriteFile(imagePath, []byte("fake"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
mgr, err := NewImageManager(io.Local, cfg)
|
mgr, err := NewImageManager(io.Local, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tempDir, "containers.json")
|
statePath := filepath.Join(tempDir, "containers.json")
|
||||||
state := container.NewState(statePath)
|
state := container.NewState(statePath)
|
||||||
h := &mockHypervisor{}
|
h := &mockHypervisor{}
|
||||||
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h)
|
||||||
|
|
@ -815,7 +815,7 @@ func TestDevOps_Boot_Success_Good(t *testing.T) {
|
||||||
assert.NoError(t, err) // Mock hypervisor succeeds
|
assert.NoError(t, err) // Mock hypervisor succeeds
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevOps_Config_Good(t *testing.T) {
|
func TestDevOps_Config(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
t.Setenv("CORE_IMAGES_DIR", tempDir)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@ package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io/fs"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-container/sources"
|
||||||
"dappco.re/go/core/container/sources"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageManager handles image downloads and updates.
|
// ImageManager handles image downloads and updates.
|
||||||
|
|
@ -37,10 +37,6 @@ type ImageInfo struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewImageManager creates a new image manager.
|
// NewImageManager creates a new image manager.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// manager, err := NewImageManager(io.Local, cfg)
|
|
||||||
func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) {
|
func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) {
|
||||||
imagesDir, err := ImagesDir()
|
imagesDir, err := ImagesDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -53,7 +49,7 @@ func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load or create manifest
|
// Load or create manifest
|
||||||
manifestPath := coreutil.JoinPath(imagesDir, "manifest.json")
|
manifestPath := filepath.Join(imagesDir, "manifest.json")
|
||||||
manifest, err := loadManifest(m, manifestPath)
|
manifest, err := loadManifest(m, manifestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -123,7 +119,7 @@ func (m *ImageManager) Install(ctx context.Context, progress func(downloaded, to
|
||||||
return coreerr.E("ImageManager.Install", "failed to get latest version", err)
|
return coreerr.E("ImageManager.Install", "failed to get latest version", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
core.Print(nil, "Downloading %s from %s...", ImageName(), src.Name())
|
fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name())
|
||||||
|
|
||||||
// Download
|
// Download
|
||||||
if err := src.Download(ctx, m.medium, imagesDir, progress); err != nil {
|
if err := src.Download(ctx, m.medium, imagesDir, progress); err != nil {
|
||||||
|
|
@ -178,15 +174,14 @@ func loadManifest(m io.Medium, path string) (*Manifest, error) {
|
||||||
|
|
||||||
content, err := m.Read(path)
|
content, err := m.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if core.Is(err, fs.ErrNotExist) {
|
if os.IsNotExist(err) {
|
||||||
return manifest, nil
|
return manifest, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := core.JSONUnmarshalString(content, manifest)
|
if err := json.Unmarshal([]byte(content), manifest); err != nil {
|
||||||
if !result.OK {
|
return nil, err
|
||||||
return nil, result.Value.(error)
|
|
||||||
}
|
}
|
||||||
manifest.medium = m
|
manifest.medium = m
|
||||||
manifest.path = path
|
manifest.path = path
|
||||||
|
|
@ -196,9 +191,9 @@ func loadManifest(m io.Medium, path string) (*Manifest, error) {
|
||||||
|
|
||||||
// Save writes the manifest to disk.
|
// Save writes the manifest to disk.
|
||||||
func (m *Manifest) Save() error {
|
func (m *Manifest) Save() error {
|
||||||
result := core.JSONMarshal(m)
|
data, err := json.MarshalIndent(m, "", " ")
|
||||||
if !result.OK {
|
if err != nil {
|
||||||
return result.Value.(error)
|
return err
|
||||||
}
|
}
|
||||||
return m.medium.Write(m.path, string(result.Value.([]byte)))
|
return m.medium.Write(m.path, string(data))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,18 @@ package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
"forge.lthn.ai/core/go-container/sources"
|
||||||
"dappco.re/go/core/container/sources"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestImageManager_IsInstalled_Good(t *testing.T) {
|
func TestImageManager_Good_IsInstalled(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -24,15 +25,15 @@ func TestImageManager_IsInstalled_Good(t *testing.T) {
|
||||||
assert.False(t, mgr.IsInstalled())
|
assert.False(t, mgr.IsInstalled())
|
||||||
|
|
||||||
// Create fake image
|
// Create fake image
|
||||||
imagePath := coreutil.JoinPath(tmpDir, ImageName())
|
imagePath := filepath.Join(tmpDir, ImageName())
|
||||||
err = io.Local.Write(imagePath, "fake")
|
err = os.WriteFile(imagePath, []byte("fake"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Now installed
|
// Now installed
|
||||||
assert.True(t, mgr.IsInstalled())
|
assert.True(t, mgr.IsInstalled())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImages_NewImageManager_Good(t *testing.T) {
|
func TestNewImageManager_Good(t *testing.T) {
|
||||||
t.Run("creates manager with cdn source", func(t *testing.T) {
|
t.Run("creates manager with cdn source", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
@ -62,9 +63,9 @@ func TestImages_NewImageManager_Good(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManifest_Save_Good(t *testing.T) {
|
func TestManifest_Save(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
path := coreutil.JoinPath(tmpDir, "manifest.json")
|
path := filepath.Join(tmpDir, "manifest.json")
|
||||||
|
|
||||||
m := &Manifest{
|
m := &Manifest{
|
||||||
medium: io.Local,
|
medium: io.Local,
|
||||||
|
|
@ -81,7 +82,8 @@ func TestManifest_Save_Good(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify file exists and has content
|
// Verify file exists and has content
|
||||||
assert.True(t, io.Local.IsFile(path))
|
_, err = os.Stat(path)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Reload
|
// Reload
|
||||||
m2, err := loadManifest(io.Local, path)
|
m2, err := loadManifest(io.Local, path)
|
||||||
|
|
@ -89,11 +91,11 @@ func TestManifest_Save_Good(t *testing.T) {
|
||||||
assert.Equal(t, "1.0.0", m2.Images["test.img"].Version)
|
assert.Equal(t, "1.0.0", m2.Images["test.img"].Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImages_LoadManifest_Bad(t *testing.T) {
|
func TestLoadManifest_Bad(t *testing.T) {
|
||||||
t.Run("invalid json", func(t *testing.T) {
|
t.Run("invalid json", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
path := coreutil.JoinPath(tmpDir, "manifest.json")
|
path := filepath.Join(tmpDir, "manifest.json")
|
||||||
err := io.Local.Write(path, "invalid json")
|
err := os.WriteFile(path, []byte("invalid json"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = loadManifest(io.Local, path)
|
_, err = loadManifest(io.Local, path)
|
||||||
|
|
@ -101,7 +103,7 @@ func TestImages_LoadManifest_Bad(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImages_CheckUpdate_Bad(t *testing.T) {
|
func TestCheckUpdate_Bad(t *testing.T) {
|
||||||
t.Run("image not installed", func(t *testing.T) {
|
t.Run("image not installed", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
@ -116,7 +118,7 @@ func TestImages_CheckUpdate_Bad(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewImageManager_AutoSource_Good(t *testing.T) {
|
func TestNewImageManager_Good_AutoSource(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -129,7 +131,7 @@ func TestNewImageManager_AutoSource_Good(t *testing.T) {
|
||||||
assert.Len(t, mgr.sources, 2) // github and cdn
|
assert.Len(t, mgr.sources, 2) // github and cdn
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewImageManager_UnknownSourceFallsToAuto_Good(t *testing.T) {
|
func TestNewImageManager_Good_UnknownSourceFallsToAuto(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -142,9 +144,9 @@ func TestNewImageManager_UnknownSourceFallsToAuto_Good(t *testing.T) {
|
||||||
assert.Len(t, mgr.sources, 2) // falls to default (auto) which is github + cdn
|
assert.Len(t, mgr.sources, 2) // falls to default (auto) which is github + cdn
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadManifest_Empty_Good(t *testing.T) {
|
func TestLoadManifest_Good_Empty(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
path := coreutil.JoinPath(tmpDir, "nonexistent.json")
|
path := filepath.Join(tmpDir, "nonexistent.json")
|
||||||
|
|
||||||
m, err := loadManifest(io.Local, path)
|
m, err := loadManifest(io.Local, path)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -154,12 +156,12 @@ func TestLoadManifest_Empty_Good(t *testing.T) {
|
||||||
assert.Equal(t, path, m.path)
|
assert.Equal(t, path, m.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadManifest_ExistingData_Good(t *testing.T) {
|
func TestLoadManifest_Good_ExistingData(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
path := coreutil.JoinPath(tmpDir, "manifest.json")
|
path := filepath.Join(tmpDir, "manifest.json")
|
||||||
|
|
||||||
data := `{"images":{"test.img":{"version":"2.0.0","source":"cdn"}}}`
|
data := `{"images":{"test.img":{"version":"2.0.0","source":"cdn"}}}`
|
||||||
err := io.Local.Write(path, data)
|
err := os.WriteFile(path, []byte(data), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
m, err := loadManifest(io.Local, path)
|
m, err := loadManifest(io.Local, path)
|
||||||
|
|
@ -169,7 +171,7 @@ func TestLoadManifest_ExistingData_Good(t *testing.T) {
|
||||||
assert.Equal(t, "cdn", m.Images["test.img"].Source)
|
assert.Equal(t, "cdn", m.Images["test.img"].Source)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageInfo_Struct_Good(t *testing.T) {
|
func TestImageInfo_Struct(t *testing.T) {
|
||||||
info := ImageInfo{
|
info := ImageInfo{
|
||||||
Version: "1.0.0",
|
Version: "1.0.0",
|
||||||
SHA256: "abc123",
|
SHA256: "abc123",
|
||||||
|
|
@ -182,9 +184,9 @@ func TestImageInfo_Struct_Good(t *testing.T) {
|
||||||
assert.Equal(t, "github", info.Source)
|
assert.Equal(t, "github", info.Source)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManifest_Save_CreatesDirs_Good(t *testing.T) {
|
func TestManifest_Save_Good_CreatesDirs(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
nestedPath := coreutil.JoinPath(tmpDir, "nested", "dir", "manifest.json")
|
nestedPath := filepath.Join(tmpDir, "nested", "dir", "manifest.json")
|
||||||
|
|
||||||
m := &Manifest{
|
m := &Manifest{
|
||||||
medium: io.Local,
|
medium: io.Local,
|
||||||
|
|
@ -198,12 +200,13 @@ func TestManifest_Save_CreatesDirs_Good(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify file was created
|
// Verify file was created
|
||||||
assert.True(t, io.Local.IsFile(nestedPath))
|
_, err = os.Stat(nestedPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManifest_Save_Overwrite_Good(t *testing.T) {
|
func TestManifest_Save_Good_Overwrite(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
path := coreutil.JoinPath(tmpDir, "manifest.json")
|
path := filepath.Join(tmpDir, "manifest.json")
|
||||||
|
|
||||||
// First save
|
// First save
|
||||||
m1 := &Manifest{
|
m1 := &Manifest{
|
||||||
|
|
@ -233,7 +236,7 @@ func TestManifest_Save_Overwrite_Good(t *testing.T) {
|
||||||
assert.False(t, exists)
|
assert.False(t, exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_Install_NoSourceAvailable_Bad(t *testing.T) {
|
func TestImageManager_Install_Bad_NoSourceAvailable(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -241,7 +244,7 @@ func TestImageManager_Install_NoSourceAvailable_Bad(t *testing.T) {
|
||||||
mgr := &ImageManager{
|
mgr := &ImageManager{
|
||||||
medium: io.Local,
|
medium: io.Local,
|
||||||
config: DefaultConfig(),
|
config: DefaultConfig(),
|
||||||
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
|
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
|
||||||
sources: nil, // no sources
|
sources: nil, // no sources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,9 +253,9 @@ func TestImageManager_Install_NoSourceAvailable_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "no image source available")
|
assert.Contains(t, err.Error(), "no image source available")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewImageManager_CreatesDir_Good(t *testing.T) {
|
func TestNewImageManager_Good_CreatesDir(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
imagesDir := coreutil.JoinPath(tmpDir, "images")
|
imagesDir := filepath.Join(tmpDir, "images")
|
||||||
t.Setenv("CORE_IMAGES_DIR", imagesDir)
|
t.Setenv("CORE_IMAGES_DIR", imagesDir)
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
@ -261,7 +264,7 @@ func TestNewImageManager_CreatesDir_Good(t *testing.T) {
|
||||||
assert.NotNil(t, mgr)
|
assert.NotNil(t, mgr)
|
||||||
|
|
||||||
// Verify directory was created
|
// Verify directory was created
|
||||||
info, err := io.Local.Stat(imagesDir)
|
info, err := os.Stat(imagesDir)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, info.IsDir())
|
assert.True(t, info.IsDir())
|
||||||
}
|
}
|
||||||
|
|
@ -285,11 +288,11 @@ func (m *mockImageSource) Download(ctx context.Context, medium io.Medium, dest s
|
||||||
return m.downloadErr
|
return m.downloadErr
|
||||||
}
|
}
|
||||||
// Create a fake image file
|
// Create a fake image file
|
||||||
imagePath := coreutil.JoinPath(dest, ImageName())
|
imagePath := filepath.Join(dest, ImageName())
|
||||||
return medium.Write(imagePath, "mock image content")
|
return os.WriteFile(imagePath, []byte("mock image content"), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_Install_WithMockSource_Good(t *testing.T) {
|
func TestImageManager_Install_Good_WithMockSource(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -302,7 +305,7 @@ func TestImageManager_Install_WithMockSource_Good(t *testing.T) {
|
||||||
mgr := &ImageManager{
|
mgr := &ImageManager{
|
||||||
medium: io.Local,
|
medium: io.Local,
|
||||||
config: DefaultConfig(),
|
config: DefaultConfig(),
|
||||||
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
|
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
|
||||||
sources: []sources.ImageSource{mock},
|
sources: []sources.ImageSource{mock},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,7 +320,7 @@ func TestImageManager_Install_WithMockSource_Good(t *testing.T) {
|
||||||
assert.Equal(t, "mock", info.Source)
|
assert.Equal(t, "mock", info.Source)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_Install_DownloadError_Bad(t *testing.T) {
|
func TestImageManager_Install_Bad_DownloadError(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -331,7 +334,7 @@ func TestImageManager_Install_DownloadError_Bad(t *testing.T) {
|
||||||
mgr := &ImageManager{
|
mgr := &ImageManager{
|
||||||
medium: io.Local,
|
medium: io.Local,
|
||||||
config: DefaultConfig(),
|
config: DefaultConfig(),
|
||||||
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
|
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
|
||||||
sources: []sources.ImageSource{mock},
|
sources: []sources.ImageSource{mock},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,7 +342,7 @@ func TestImageManager_Install_DownloadError_Bad(t *testing.T) {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_Install_VersionError_Bad(t *testing.T) {
|
func TestImageManager_Install_Bad_VersionError(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -352,7 +355,7 @@ func TestImageManager_Install_VersionError_Bad(t *testing.T) {
|
||||||
mgr := &ImageManager{
|
mgr := &ImageManager{
|
||||||
medium: io.Local,
|
medium: io.Local,
|
||||||
config: DefaultConfig(),
|
config: DefaultConfig(),
|
||||||
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
|
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
|
||||||
sources: []sources.ImageSource{mock},
|
sources: []sources.ImageSource{mock},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,7 +364,7 @@ func TestImageManager_Install_VersionError_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "failed to get latest version")
|
assert.Contains(t, err.Error(), "failed to get latest version")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_Install_SkipsUnavailableSource_Good(t *testing.T) {
|
func TestImageManager_Install_Good_SkipsUnavailableSource(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -378,7 +381,7 @@ func TestImageManager_Install_SkipsUnavailableSource_Good(t *testing.T) {
|
||||||
mgr := &ImageManager{
|
mgr := &ImageManager{
|
||||||
medium: io.Local,
|
medium: io.Local,
|
||||||
config: DefaultConfig(),
|
config: DefaultConfig(),
|
||||||
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
|
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
|
||||||
sources: []sources.ImageSource{unavailableMock, availableMock},
|
sources: []sources.ImageSource{unavailableMock, availableMock},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,7 +393,7 @@ func TestImageManager_Install_SkipsUnavailableSource_Good(t *testing.T) {
|
||||||
assert.Equal(t, "available", info.Source)
|
assert.Equal(t, "available", info.Source)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_CheckUpdate_WithMockSource_Good(t *testing.T) {
|
func TestImageManager_CheckUpdate_Good_WithMockSource(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -408,7 +411,7 @@ func TestImageManager_CheckUpdate_WithMockSource_Good(t *testing.T) {
|
||||||
Images: map[string]ImageInfo{
|
Images: map[string]ImageInfo{
|
||||||
ImageName(): {Version: "v1.0.0", Source: "mock"},
|
ImageName(): {Version: "v1.0.0", Source: "mock"},
|
||||||
},
|
},
|
||||||
path: coreutil.JoinPath(tmpDir, "manifest.json"),
|
path: filepath.Join(tmpDir, "manifest.json"),
|
||||||
},
|
},
|
||||||
sources: []sources.ImageSource{mock},
|
sources: []sources.ImageSource{mock},
|
||||||
}
|
}
|
||||||
|
|
@ -420,7 +423,7 @@ func TestImageManager_CheckUpdate_WithMockSource_Good(t *testing.T) {
|
||||||
assert.True(t, hasUpdate)
|
assert.True(t, hasUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_CheckUpdate_NoUpdate_Good(t *testing.T) {
|
func TestImageManager_CheckUpdate_Good_NoUpdate(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -438,7 +441,7 @@ func TestImageManager_CheckUpdate_NoUpdate_Good(t *testing.T) {
|
||||||
Images: map[string]ImageInfo{
|
Images: map[string]ImageInfo{
|
||||||
ImageName(): {Version: "v1.0.0", Source: "mock"},
|
ImageName(): {Version: "v1.0.0", Source: "mock"},
|
||||||
},
|
},
|
||||||
path: coreutil.JoinPath(tmpDir, "manifest.json"),
|
path: filepath.Join(tmpDir, "manifest.json"),
|
||||||
},
|
},
|
||||||
sources: []sources.ImageSource{mock},
|
sources: []sources.ImageSource{mock},
|
||||||
}
|
}
|
||||||
|
|
@ -450,7 +453,7 @@ func TestImageManager_CheckUpdate_NoUpdate_Good(t *testing.T) {
|
||||||
assert.False(t, hasUpdate)
|
assert.False(t, hasUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_CheckUpdate_NoSource_Bad(t *testing.T) {
|
func TestImageManager_CheckUpdate_Bad_NoSource(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -467,7 +470,7 @@ func TestImageManager_CheckUpdate_NoSource_Bad(t *testing.T) {
|
||||||
Images: map[string]ImageInfo{
|
Images: map[string]ImageInfo{
|
||||||
ImageName(): {Version: "v1.0.0", Source: "mock"},
|
ImageName(): {Version: "v1.0.0", Source: "mock"},
|
||||||
},
|
},
|
||||||
path: coreutil.JoinPath(tmpDir, "manifest.json"),
|
path: filepath.Join(tmpDir, "manifest.json"),
|
||||||
},
|
},
|
||||||
sources: []sources.ImageSource{unavailableMock},
|
sources: []sources.ImageSource{unavailableMock},
|
||||||
}
|
}
|
||||||
|
|
@ -477,7 +480,7 @@ func TestImageManager_CheckUpdate_NoSource_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "no image source available")
|
assert.Contains(t, err.Error(), "no image source available")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_CheckUpdate_VersionError_Bad(t *testing.T) {
|
func TestImageManager_CheckUpdate_Bad_VersionError(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -495,7 +498,7 @@ func TestImageManager_CheckUpdate_VersionError_Bad(t *testing.T) {
|
||||||
Images: map[string]ImageInfo{
|
Images: map[string]ImageInfo{
|
||||||
ImageName(): {Version: "v1.0.0", Source: "mock"},
|
ImageName(): {Version: "v1.0.0", Source: "mock"},
|
||||||
},
|
},
|
||||||
path: coreutil.JoinPath(tmpDir, "manifest.json"),
|
path: filepath.Join(tmpDir, "manifest.json"),
|
||||||
},
|
},
|
||||||
sources: []sources.ImageSource{mock},
|
sources: []sources.ImageSource{mock},
|
||||||
}
|
}
|
||||||
|
|
@ -505,14 +508,14 @@ func TestImageManager_CheckUpdate_VersionError_Bad(t *testing.T) {
|
||||||
assert.Equal(t, "v1.0.0", current) // Current should still be returned
|
assert.Equal(t, "v1.0.0", current) // Current should still be returned
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_Install_EmptySources_Bad(t *testing.T) {
|
func TestImageManager_Install_Bad_EmptySources(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
mgr := &ImageManager{
|
mgr := &ImageManager{
|
||||||
medium: io.Local,
|
medium: io.Local,
|
||||||
config: DefaultConfig(),
|
config: DefaultConfig(),
|
||||||
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
|
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
|
||||||
sources: []sources.ImageSource{}, // Empty slice, not nil
|
sources: []sources.ImageSource{}, // Empty slice, not nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -521,7 +524,7 @@ func TestImageManager_Install_EmptySources_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "no image source available")
|
assert.Contains(t, err.Error(), "no image source available")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_Install_AllUnavailable_Bad(t *testing.T) {
|
func TestImageManager_Install_Bad_AllUnavailable(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -531,7 +534,7 @@ func TestImageManager_Install_AllUnavailable_Bad(t *testing.T) {
|
||||||
mgr := &ImageManager{
|
mgr := &ImageManager{
|
||||||
medium: io.Local,
|
medium: io.Local,
|
||||||
config: DefaultConfig(),
|
config: DefaultConfig(),
|
||||||
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
|
manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
|
||||||
sources: []sources.ImageSource{mock1, mock2},
|
sources: []sources.ImageSource{mock1, mock2},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -540,7 +543,7 @@ func TestImageManager_Install_AllUnavailable_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "no image source available")
|
assert.Contains(t, err.Error(), "no image source available")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageManager_CheckUpdate_FirstSourceUnavailable_Good(t *testing.T) {
|
func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
t.Setenv("CORE_IMAGES_DIR", tmpDir)
|
||||||
|
|
||||||
|
|
@ -555,7 +558,7 @@ func TestImageManager_CheckUpdate_FirstSourceUnavailable_Good(t *testing.T) {
|
||||||
Images: map[string]ImageInfo{
|
Images: map[string]ImageInfo{
|
||||||
ImageName(): {Version: "v1.0.0", Source: "available"},
|
ImageName(): {Version: "v1.0.0", Source: "available"},
|
||||||
},
|
},
|
||||||
path: coreutil.JoinPath(tmpDir, "manifest.json"),
|
path: filepath.Join(tmpDir, "manifest.json"),
|
||||||
},
|
},
|
||||||
sources: []sources.ImageSource{unavailable, available},
|
sources: []sources.ImageSource{unavailable, available},
|
||||||
}
|
}
|
||||||
|
|
@ -567,7 +570,7 @@ func TestImageManager_CheckUpdate_FirstSourceUnavailable_Good(t *testing.T) {
|
||||||
assert.True(t, hasUpdate)
|
assert.True(t, hasUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManifest_Struct_Good(t *testing.T) {
|
func TestManifest_Struct(t *testing.T) {
|
||||||
m := &Manifest{
|
m := &Manifest{
|
||||||
Images: map[string]ImageInfo{
|
Images: map[string]ImageInfo{
|
||||||
"test.img": {Version: "1.0.0"},
|
"test.img": {Version: "1.0.0"},
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServeOptions configures the dev server.
|
// ServeOptions configures the dev server.
|
||||||
|
|
@ -33,7 +33,7 @@ func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions
|
||||||
|
|
||||||
servePath := projectDir
|
servePath := projectDir
|
||||||
if opts.Path != "" {
|
if opts.Path != "" {
|
||||||
servePath = coreutil.JoinPath(projectDir, opts.Path)
|
servePath = filepath.Join(projectDir, opts.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount project directory via SSHFS
|
// Mount project directory via SSHFS
|
||||||
|
|
@ -43,8 +43,8 @@ func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions
|
||||||
|
|
||||||
// Detect and run serve command
|
// Detect and run serve command
|
||||||
serveCmd := DetectServeCommand(d.medium, servePath)
|
serveCmd := DetectServeCommand(d.medium, servePath)
|
||||||
core.Print(nil, "Starting server: %s", serveCmd)
|
fmt.Printf("Starting server: %s\n", serveCmd)
|
||||||
core.Print(nil, "Listening on http://localhost:%d", opts.Port)
|
fmt.Printf("Listening on http://localhost:%d\n", opts.Port)
|
||||||
|
|
||||||
// Run serve command via SSH
|
// Run serve command via SSH
|
||||||
return d.sshShell(ctx, []string{"cd", "/app", "&&", serveCmd})
|
return d.sshShell(ctx, []string{"cd", "/app", "&&", serveCmd})
|
||||||
|
|
@ -52,27 +52,26 @@ func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions
|
||||||
|
|
||||||
// mountProject mounts a directory into the VM via SSHFS.
|
// mountProject mounts a directory into the VM via SSHFS.
|
||||||
func (d *DevOps) mountProject(ctx context.Context, path string) error {
|
func (d *DevOps) mountProject(ctx context.Context, path string) error {
|
||||||
absPath := coreutil.AbsPath(path)
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Use reverse SSHFS mount
|
// Use reverse SSHFS mount
|
||||||
// The VM connects back to host to mount the directory
|
// The VM connects back to host to mount the directory
|
||||||
cmd := proc.NewCommandContext(ctx, "ssh",
|
cmd := exec.CommandContext(ctx, "ssh",
|
||||||
"-o", "StrictHostKeyChecking=yes",
|
"-o", "StrictHostKeyChecking=yes",
|
||||||
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
||||||
"-o", "LogLevel=ERROR",
|
"-o", "LogLevel=ERROR",
|
||||||
"-R", "10000:localhost:22", // Reverse tunnel for SSHFS
|
"-R", "10000:localhost:22", // Reverse tunnel for SSHFS
|
||||||
"-p", core.Sprintf("%d", DefaultSSHPort),
|
"-p", fmt.Sprintf("%d", DefaultSSHPort),
|
||||||
"root@localhost",
|
"root@localhost",
|
||||||
core.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", core.Env("USER"), absPath),
|
fmt.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", os.Getenv("USER"), absPath),
|
||||||
)
|
)
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectServeCommand auto-detects the serve command for a project.
|
// DetectServeCommand auto-detects the serve command for a project.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// cmd := DetectServeCommand(io.Local, ".")
|
|
||||||
func DetectServeCommand(m io.Medium, projectDir string) string {
|
func DetectServeCommand(m io.Medium, projectDir string) string {
|
||||||
// Laravel/Octane
|
// Laravel/Octane
|
||||||
if hasFile(m, projectDir, "artisan") {
|
if hasFile(m, projectDir, "artisan") {
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,66 @@
|
||||||
package devenv
|
package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDetectServeCommand_Laravel_Good(t *testing.T) {
|
func TestDetectServeCommand_Good_Laravel(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "artisan"), "#!/usr/bin/env php")
|
err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
cmd := DetectServeCommand(io.Local, tmpDir)
|
cmd := DetectServeCommand(io.Local, tmpDir)
|
||||||
assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd)
|
assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectServeCommand_NodeDev_Good(t *testing.T) {
|
func TestDetectServeCommand_Good_NodeDev(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
packageJSON := `{"scripts":{"dev":"vite","start":"node index.js"}}`
|
packageJSON := `{"scripts":{"dev":"vite","start":"node index.js"}}`
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), packageJSON)
|
err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
cmd := DetectServeCommand(io.Local, tmpDir)
|
cmd := DetectServeCommand(io.Local, tmpDir)
|
||||||
assert.Equal(t, "npm run dev -- --host 0.0.0.0", cmd)
|
assert.Equal(t, "npm run dev -- --host 0.0.0.0", cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectServeCommand_NodeStart_Good(t *testing.T) {
|
func TestDetectServeCommand_Good_NodeStart(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
packageJSON := `{"scripts":{"start":"node server.js"}}`
|
packageJSON := `{"scripts":{"start":"node server.js"}}`
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), packageJSON)
|
err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
cmd := DetectServeCommand(io.Local, tmpDir)
|
cmd := DetectServeCommand(io.Local, tmpDir)
|
||||||
assert.Equal(t, "npm start", cmd)
|
assert.Equal(t, "npm start", cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectServeCommand_PHP_Good(t *testing.T) {
|
func TestDetectServeCommand_Good_PHP(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"require":{}}`)
|
err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
cmd := DetectServeCommand(io.Local, tmpDir)
|
cmd := DetectServeCommand(io.Local, tmpDir)
|
||||||
assert.Equal(t, "frankenphp php-server -l :8000", cmd)
|
assert.Equal(t, "frankenphp php-server -l :8000", cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectServeCommand_GoMain_Good(t *testing.T) {
|
func TestDetectServeCommand_Good_GoMain(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example")
|
err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
err = io.Local.Write(coreutil.JoinPath(tmpDir, "main.go"), "package main")
|
err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
cmd := DetectServeCommand(io.Local, tmpDir)
|
cmd := DetectServeCommand(io.Local, tmpDir)
|
||||||
assert.Equal(t, "go run .", cmd)
|
assert.Equal(t, "go run .", cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectServeCommand_GoWithoutMain_Good(t *testing.T) {
|
func TestDetectServeCommand_Good_GoWithoutMain(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example")
|
err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// No main.go, so falls through to fallback
|
// No main.go, so falls through to fallback
|
||||||
|
|
@ -67,41 +68,41 @@ func TestDetectServeCommand_GoWithoutMain_Good(t *testing.T) {
|
||||||
assert.Equal(t, "python3 -m http.server 8000", cmd)
|
assert.Equal(t, "python3 -m http.server 8000", cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectServeCommand_Django_Good(t *testing.T) {
|
func TestDetectServeCommand_Good_Django(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "manage.py"), "#!/usr/bin/env python")
|
err := os.WriteFile(filepath.Join(tmpDir, "manage.py"), []byte("#!/usr/bin/env python"), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
cmd := DetectServeCommand(io.Local, tmpDir)
|
cmd := DetectServeCommand(io.Local, tmpDir)
|
||||||
assert.Equal(t, "python manage.py runserver 0.0.0.0:8000", cmd)
|
assert.Equal(t, "python manage.py runserver 0.0.0.0:8000", cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectServeCommand_Fallback_Good(t *testing.T) {
|
func TestDetectServeCommand_Good_Fallback(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
cmd := DetectServeCommand(io.Local, tmpDir)
|
cmd := DetectServeCommand(io.Local, tmpDir)
|
||||||
assert.Equal(t, "python3 -m http.server 8000", cmd)
|
assert.Equal(t, "python3 -m http.server 8000", cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectServeCommand_Priority_Good(t *testing.T) {
|
func TestDetectServeCommand_Good_Priority(t *testing.T) {
|
||||||
// Laravel (artisan) should take priority over PHP (composer.json)
|
// Laravel (artisan) should take priority over PHP (composer.json)
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "artisan"), "#!/usr/bin/env php")
|
err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
err = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"require":{}}`)
|
err = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
cmd := DetectServeCommand(io.Local, tmpDir)
|
cmd := DetectServeCommand(io.Local, tmpDir)
|
||||||
assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd)
|
assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServeOptions_Default_Good(t *testing.T) {
|
func TestServeOptions_Default(t *testing.T) {
|
||||||
opts := ServeOptions{}
|
opts := ServeOptions{}
|
||||||
assert.Equal(t, 0, opts.Port)
|
assert.Equal(t, 0, opts.Port)
|
||||||
assert.Equal(t, "", opts.Path)
|
assert.Equal(t, "", opts.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServeOptions_Custom_Good(t *testing.T) {
|
func TestServeOptions_Custom(t *testing.T) {
|
||||||
opts := ServeOptions{
|
opts := ServeOptions{
|
||||||
Port: 3000,
|
Port: 3000,
|
||||||
Path: "public",
|
Path: "public",
|
||||||
|
|
@ -110,25 +111,25 @@ func TestServeOptions_Custom_Good(t *testing.T) {
|
||||||
assert.Equal(t, "public", opts.Path)
|
assert.Equal(t, "public", opts.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServe_HasFile_Good(t *testing.T) {
|
func TestHasFile_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
testFile := coreutil.JoinPath(tmpDir, "test.txt")
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
err := io.Local.Write(testFile, "content")
|
err := os.WriteFile(testFile, []byte("content"), 0644)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.True(t, hasFile(io.Local, tmpDir, "test.txt"))
|
assert.True(t, hasFile(io.Local, tmpDir, "test.txt"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServe_HasFile_Bad(t *testing.T) {
|
func TestHasFile_Bad(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
assert.False(t, hasFile(io.Local, tmpDir, "nonexistent.txt"))
|
assert.False(t, hasFile(io.Local, tmpDir, "nonexistent.txt"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasFile_Directory_Bad(t *testing.T) {
|
func TestHasFile_Bad_Directory(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
subDir := coreutil.JoinPath(tmpDir, "subdir")
|
subDir := filepath.Join(tmpDir, "subdir")
|
||||||
err := io.Local.EnsureDir(subDir)
|
err := os.Mkdir(subDir, 0755)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// hasFile correctly returns false for directories (only true for regular files)
|
// hasFile correctly returns false for directories (only true for regular files)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ShellOptions configures the shell connection.
|
// ShellOptions configures the shell connection.
|
||||||
|
|
@ -39,7 +39,7 @@ func (d *DevOps) sshShell(ctx context.Context, command []string) error {
|
||||||
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
||||||
"-o", "LogLevel=ERROR",
|
"-o", "LogLevel=ERROR",
|
||||||
"-A", // Agent forwarding
|
"-A", // Agent forwarding
|
||||||
"-p", core.Sprintf("%d", DefaultSSHPort),
|
"-p", fmt.Sprintf("%d", DefaultSSHPort),
|
||||||
"root@localhost",
|
"root@localhost",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,10 +47,10 @@ func (d *DevOps) sshShell(ctx context.Context, command []string) error {
|
||||||
args = append(args, command...)
|
args = append(args, command...)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := proc.NewCommandContext(ctx, "ssh", args...)
|
cmd := exec.CommandContext(ctx, "ssh", args...)
|
||||||
cmd.Stdin = proc.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = proc.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = proc.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
@ -67,10 +67,10 @@ func (d *DevOps) serialConsole(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use socat to connect to the console socket
|
// Use socat to connect to the console socket
|
||||||
socketPath := core.Sprintf("/tmp/core-%s-console.sock", c.ID)
|
socketPath := fmt.Sprintf("/tmp/core-%s-console.sock", c.ID)
|
||||||
cmd := proc.NewCommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath)
|
cmd := exec.CommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath)
|
||||||
cmd.Stdin = proc.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = proc.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = proc.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestShellOptions_Default_Good(t *testing.T) {
|
func TestShellOptions_Default(t *testing.T) {
|
||||||
opts := ShellOptions{}
|
opts := ShellOptions{}
|
||||||
assert.False(t, opts.Console)
|
assert.False(t, opts.Console)
|
||||||
assert.Nil(t, opts.Command)
|
assert.Nil(t, opts.Command)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShellOptions_Console_Good(t *testing.T) {
|
func TestShellOptions_Console(t *testing.T) {
|
||||||
opts := ShellOptions{
|
opts := ShellOptions{
|
||||||
Console: true,
|
Console: true,
|
||||||
}
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ func TestShellOptions_Console_Good(t *testing.T) {
|
||||||
assert.Nil(t, opts.Command)
|
assert.Nil(t, opts.Command)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShellOptions_Command_Good(t *testing.T) {
|
func TestShellOptions_Command(t *testing.T) {
|
||||||
opts := ShellOptions{
|
opts := ShellOptions{
|
||||||
Command: []string{"ls", "-la"},
|
Command: []string{"ls", "-la"},
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ func TestShellOptions_Command_Good(t *testing.T) {
|
||||||
assert.Equal(t, []string{"ls", "-la"}, opts.Command)
|
assert.Equal(t, []string{"ls", "-la"}, opts.Command)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShellOptions_ConsoleWithCommand_Good(t *testing.T) {
|
func TestShellOptions_ConsoleWithCommand(t *testing.T) {
|
||||||
opts := ShellOptions{
|
opts := ShellOptions{
|
||||||
Console: true,
|
Console: true,
|
||||||
Command: []string{"echo", "hello"},
|
Command: []string{"echo", "hello"},
|
||||||
|
|
@ -37,7 +37,7 @@ func TestShellOptions_ConsoleWithCommand_Good(t *testing.T) {
|
||||||
assert.Equal(t, []string{"echo", "hello"}, opts.Command)
|
assert.Equal(t, []string{"echo", "hello"}, opts.Command)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShellOptions_EmptyCommand_Good(t *testing.T) {
|
func TestShellOptions_EmptyCommand(t *testing.T) {
|
||||||
opts := ShellOptions{
|
opts := ShellOptions{
|
||||||
Command: []string{},
|
Command: []string{},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,38 @@ package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
coreio "forge.lthn.ai/core/go-io"
|
||||||
coreio "dappco.re/go/core/io"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ensureHostKey ensures that the host key for the dev environment is in the known hosts file.
|
// ensureHostKey ensures that the host key for the dev environment is in the known hosts file.
|
||||||
// This is used after boot to allow StrictHostKeyChecking=yes to work.
|
// This is used after boot to allow StrictHostKeyChecking=yes to work.
|
||||||
func ensureHostKey(ctx context.Context, port int) error {
|
func ensureHostKey(ctx context.Context, port int) error {
|
||||||
// Skip if requested (used in tests)
|
// Skip if requested (used in tests)
|
||||||
if core.Env("CORE_SKIP_SSH_SCAN") == "true" {
|
if os.Getenv("CORE_SKIP_SSH_SCAN") == "true" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
home := coreutil.HomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if home == "" {
|
if err != nil {
|
||||||
return coreerr.E("ensureHostKey", "get home dir", nil)
|
return coreerr.E("ensureHostKey", "get home dir", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
knownHostsPath := coreutil.JoinPath(home, ".core", "known_hosts")
|
knownHostsPath := filepath.Join(home, ".core", "known_hosts")
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
if err := coreio.Local.EnsureDir(core.PathDir(knownHostsPath)); err != nil {
|
if err := coreio.Local.EnsureDir(filepath.Dir(knownHostsPath)); err != nil {
|
||||||
return coreerr.E("ensureHostKey", "create known_hosts dir", err)
|
return coreerr.E("ensureHostKey", "create known_hosts dir", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get host key using ssh-keyscan
|
// Get host key using ssh-keyscan
|
||||||
cmd := proc.NewCommandContext(ctx, "ssh-keyscan", "-p", core.Sprintf("%d", port), "localhost")
|
cmd := exec.CommandContext(ctx, "ssh-keyscan", "-p", fmt.Sprintf("%d", port), "localhost")
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("ensureHostKey", "ssh-keyscan failed", err)
|
return coreerr.E("ensureHostKey", "ssh-keyscan failed", err)
|
||||||
|
|
@ -45,27 +46,21 @@ func ensureHostKey(ctx context.Context, port int) error {
|
||||||
// Read existing known_hosts to avoid duplicates
|
// Read existing known_hosts to avoid duplicates
|
||||||
existingStr, _ := coreio.Local.Read(knownHostsPath)
|
existingStr, _ := coreio.Local.Read(knownHostsPath)
|
||||||
|
|
||||||
if !coreio.Local.Exists(knownHostsPath) {
|
|
||||||
if err := coreio.Local.WriteMode(knownHostsPath, "", 0600); err != nil {
|
|
||||||
return coreerr.E("ensureHostKey", "create known_hosts", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append new keys that aren't already there
|
// Append new keys that aren't already there
|
||||||
f, err := coreio.Local.Append(knownHostsPath)
|
f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("ensureHostKey", "open known_hosts", err)
|
return coreerr.E("ensureHostKey", "open known_hosts", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
lines := core.Split(string(out), "\n")
|
lines := strings.Split(string(out), "\n")
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
line = core.Trim(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || core.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !core.Contains(existingStr, line) {
|
if !strings.Contains(existingStr, line) {
|
||||||
if _, err := f.Write([]byte(core.Concat(line, "\n"))); err != nil {
|
if _, err := f.WriteString(line + "\n"); err != nil {
|
||||||
return coreerr.E("ensureHostKey", "write known_hosts", err)
|
return coreerr.E("ensureHostKey", "write known_hosts", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestConfig holds test configuration from .core/test.yaml.
|
// TestConfig holds test configuration from .core/test.yaml.
|
||||||
|
|
@ -45,7 +45,7 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions)
|
||||||
|
|
||||||
// Priority: explicit command > named command > auto-detect
|
// Priority: explicit command > named command > auto-detect
|
||||||
if len(opts.Command) > 0 {
|
if len(opts.Command) > 0 {
|
||||||
cmd = core.Join(" ", opts.Command...)
|
cmd = strings.Join(opts.Command, " ")
|
||||||
} else if opts.Name != "" {
|
} else if opts.Name != "" {
|
||||||
cfg, err := LoadTestConfig(d.medium, projectDir)
|
cfg, err := LoadTestConfig(d.medium, projectDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -72,10 +72,6 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectTestCommand auto-detects the test command for a project.
|
// DetectTestCommand auto-detects the test command for a project.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// cmd := DetectTestCommand(io.Local, ".")
|
|
||||||
func DetectTestCommand(m io.Medium, projectDir string) string {
|
func DetectTestCommand(m io.Medium, projectDir string) string {
|
||||||
// 1. Check .core/test.yaml
|
// 1. Check .core/test.yaml
|
||||||
cfg, err := LoadTestConfig(m, projectDir)
|
cfg, err := LoadTestConfig(m, projectDir)
|
||||||
|
|
@ -116,12 +112,12 @@ func DetectTestCommand(m io.Medium, projectDir string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadTestConfig loads .core/test.yaml.
|
// LoadTestConfig loads .core/test.yaml.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// cfg, err := LoadTestConfig(io.Local, ".")
|
|
||||||
func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) {
|
func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) {
|
||||||
absPath := coreutil.AbsPath(coreutil.JoinPath(projectDir, ".core", "test.yaml"))
|
path := filepath.Join(projectDir, ".core", "test.yaml")
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
content, err := m.Read(absPath)
|
content, err := m.Read(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -137,12 +133,20 @@ func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasFile(m io.Medium, dir, name string) bool {
|
func hasFile(m io.Medium, dir, name string) bool {
|
||||||
absPath := coreutil.AbsPath(coreutil.JoinPath(dir, name))
|
path := filepath.Join(dir, name)
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return m.IsFile(absPath)
|
return m.IsFile(absPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasPackageScript(m io.Medium, projectDir, script string) bool {
|
func hasPackageScript(m io.Medium, projectDir, script string) bool {
|
||||||
absPath := coreutil.AbsPath(coreutil.JoinPath(projectDir, "package.json"))
|
path := filepath.Join(projectDir, "package.json")
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
content, err := m.Read(absPath)
|
content, err := m.Read(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -152,8 +156,7 @@ func hasPackageScript(m io.Medium, projectDir, script string) bool {
|
||||||
var pkg struct {
|
var pkg struct {
|
||||||
Scripts map[string]string `json:"scripts"`
|
Scripts map[string]string `json:"scripts"`
|
||||||
}
|
}
|
||||||
result := core.JSONUnmarshalString(content, &pkg)
|
if err := json.Unmarshal([]byte(content), &pkg); err != nil {
|
||||||
if !result.OK {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,7 +165,11 @@ func hasPackageScript(m io.Medium, projectDir, script string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasComposerScript(m io.Medium, projectDir, script string) bool {
|
func hasComposerScript(m io.Medium, projectDir, script string) bool {
|
||||||
absPath := coreutil.AbsPath(coreutil.JoinPath(projectDir, "composer.json"))
|
path := filepath.Join(projectDir, "composer.json")
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
content, err := m.Read(absPath)
|
content, err := m.Read(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -172,8 +179,7 @@ func hasComposerScript(m io.Medium, projectDir, script string) bool {
|
||||||
var pkg struct {
|
var pkg struct {
|
||||||
Scripts map[string]any `json:"scripts"`
|
Scripts map[string]any `json:"scripts"`
|
||||||
}
|
}
|
||||||
result := core.JSONUnmarshalString(content, &pkg)
|
if err := json.Unmarshal([]byte(content), &pkg); err != nil {
|
||||||
if !result.OK {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
package devenv
|
package devenv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDetectTestCommand_ComposerJSON_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"scripts":{"test":"pest"}}`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
if cmd != "composer test" {
|
if cmd != "composer test" {
|
||||||
|
|
@ -17,9 +18,9 @@ func TestDetectTestCommand_ComposerJSON_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_PackageJSON_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_PackageJSON(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"scripts":{"test":"vitest"}}`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
if cmd != "npm test" {
|
if cmd != "npm test" {
|
||||||
|
|
@ -27,9 +28,9 @@ func TestDetectTestCommand_PackageJSON_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_GoMod_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_GoMod(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example")
|
_ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
if cmd != "go test ./..." {
|
if cmd != "go test ./..." {
|
||||||
|
|
@ -37,11 +38,11 @@ func TestDetectTestCommand_GoMod_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_CoreTestYaml_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
coreDir := coreutil.JoinPath(tmpDir, ".core")
|
coreDir := filepath.Join(tmpDir, ".core")
|
||||||
_ = io.Local.EnsureDir(coreDir)
|
_ = os.MkdirAll(coreDir, 0755)
|
||||||
_ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "command: custom-test")
|
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
if cmd != "custom-test" {
|
if cmd != "custom-test" {
|
||||||
|
|
@ -49,9 +50,9 @@ func TestDetectTestCommand_CoreTestYaml_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_Pytest_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_Pytest(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "pytest.ini"), "[pytest]")
|
_ = os.WriteFile(filepath.Join(tmpDir, "pytest.ini"), []byte("[pytest]"), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
if cmd != "pytest" {
|
if cmd != "pytest" {
|
||||||
|
|
@ -59,9 +60,9 @@ func TestDetectTestCommand_Pytest_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_Taskfile_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_Taskfile(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "Taskfile.yaml"), "version: '3'")
|
_ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), []byte("version: '3'"), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
if cmd != "task test" {
|
if cmd != "task test" {
|
||||||
|
|
@ -69,7 +70,7 @@ func TestDetectTestCommand_Taskfile_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_NoFiles_Bad(t *testing.T) {
|
func TestDetectTestCommand_Bad_NoFiles(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
|
|
@ -78,13 +79,13 @@ func TestDetectTestCommand_NoFiles_Bad(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_Priority_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_Priority(t *testing.T) {
|
||||||
// .core/test.yaml should take priority over other detection methods
|
// .core/test.yaml should take priority over other detection methods
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
coreDir := coreutil.JoinPath(tmpDir, ".core")
|
coreDir := filepath.Join(tmpDir, ".core")
|
||||||
_ = io.Local.EnsureDir(coreDir)
|
_ = os.MkdirAll(coreDir, 0755)
|
||||||
_ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "command: my-custom-test")
|
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: my-custom-test"), 0644)
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example")
|
_ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
if cmd != "my-custom-test" {
|
if cmd != "my-custom-test" {
|
||||||
|
|
@ -92,10 +93,10 @@ func TestDetectTestCommand_Priority_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTest_LoadTestConfig_Good(t *testing.T) {
|
func TestLoadTestConfig_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
coreDir := coreutil.JoinPath(tmpDir, ".core")
|
coreDir := filepath.Join(tmpDir, ".core")
|
||||||
_ = io.Local.EnsureDir(coreDir)
|
_ = os.MkdirAll(coreDir, 0755)
|
||||||
|
|
||||||
configYAML := `version: 1
|
configYAML := `version: 1
|
||||||
command: default-test
|
command: default-test
|
||||||
|
|
@ -107,7 +108,7 @@ commands:
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
`
|
`
|
||||||
_ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), configYAML)
|
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte(configYAML), 0644)
|
||||||
|
|
||||||
cfg, err := LoadTestConfig(io.Local, tmpDir)
|
cfg, err := LoadTestConfig(io.Local, tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -131,7 +132,7 @@ env:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadTestConfig_NotFound_Bad(t *testing.T) {
|
func TestLoadTestConfig_Bad_NotFound(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
_, err := LoadTestConfig(io.Local, tmpDir)
|
_, err := LoadTestConfig(io.Local, tmpDir)
|
||||||
|
|
@ -140,9 +141,9 @@ func TestLoadTestConfig_NotFound_Bad(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTest_HasPackageScript_Good(t *testing.T) {
|
func TestHasPackageScript_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"scripts":{"test":"jest","build":"webpack"}}`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"jest","build":"webpack"}}`), 0644)
|
||||||
|
|
||||||
if !hasPackageScript(io.Local, tmpDir, "test") {
|
if !hasPackageScript(io.Local, tmpDir, "test") {
|
||||||
t.Error("expected to find 'test' script")
|
t.Error("expected to find 'test' script")
|
||||||
|
|
@ -152,34 +153,34 @@ func TestTest_HasPackageScript_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasPackageScript_MissingScript_Bad(t *testing.T) {
|
func TestHasPackageScript_Bad_MissingScript(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"scripts":{"build":"webpack"}}`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"build":"webpack"}}`), 0644)
|
||||||
|
|
||||||
if hasPackageScript(io.Local, tmpDir, "test") {
|
if hasPackageScript(io.Local, tmpDir, "test") {
|
||||||
t.Error("expected not to find 'test' script")
|
t.Error("expected not to find 'test' script")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTest_HasComposerScript_Good(t *testing.T) {
|
func TestHasComposerScript_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`), 0644)
|
||||||
|
|
||||||
if !hasComposerScript(io.Local, tmpDir, "test") {
|
if !hasComposerScript(io.Local, tmpDir, "test") {
|
||||||
t.Error("expected to find 'test' script")
|
t.Error("expected to find 'test' script")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasComposerScript_MissingScript_Bad(t *testing.T) {
|
func TestHasComposerScript_Bad_MissingScript(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"scripts":{"build":"@php build.php"}}`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"build":"@php build.php"}}`), 0644)
|
||||||
|
|
||||||
if hasComposerScript(io.Local, tmpDir, "test") {
|
if hasComposerScript(io.Local, tmpDir, "test") {
|
||||||
t.Error("expected not to find 'test' script")
|
t.Error("expected not to find 'test' script")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTestConfig_Struct_Good(t *testing.T) {
|
func TestTestConfig_Struct(t *testing.T) {
|
||||||
cfg := &TestConfig{
|
cfg := &TestConfig{
|
||||||
Version: 2,
|
Version: 2,
|
||||||
Command: "my-test",
|
Command: "my-test",
|
||||||
|
|
@ -200,7 +201,7 @@ func TestTestConfig_Struct_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTestCommand_Struct_Good(t *testing.T) {
|
func TestTestCommand_Struct(t *testing.T) {
|
||||||
cmd := TestCommand{
|
cmd := TestCommand{
|
||||||
Name: "integration",
|
Name: "integration",
|
||||||
Run: "go test -tags=integration ./...",
|
Run: "go test -tags=integration ./...",
|
||||||
|
|
@ -213,7 +214,7 @@ func TestTestCommand_Struct_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTestOptions_Struct_Good(t *testing.T) {
|
func TestTestOptions_Struct(t *testing.T) {
|
||||||
opts := TestOptions{
|
opts := TestOptions{
|
||||||
Name: "unit",
|
Name: "unit",
|
||||||
Command: []string{"go", "test", "-v"},
|
Command: []string{"go", "test", "-v"},
|
||||||
|
|
@ -226,9 +227,9 @@ func TestTestOptions_Struct_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_TaskfileYml_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_TaskfileYml(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "Taskfile.yml"), "version: '3'")
|
_ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yml"), []byte("version: '3'"), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
if cmd != "task test" {
|
if cmd != "task test" {
|
||||||
|
|
@ -236,9 +237,9 @@ func TestDetectTestCommand_TaskfileYml_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_Pyproject_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_Pyproject(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "pyproject.toml"), "[tool.pytest]")
|
_ = os.WriteFile(filepath.Join(tmpDir, "pyproject.toml"), []byte("[tool.pytest]"), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
if cmd != "pytest" {
|
if cmd != "pytest" {
|
||||||
|
|
@ -246,7 +247,7 @@ func TestDetectTestCommand_Pyproject_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasPackageScript_NoFile_Bad(t *testing.T) {
|
func TestHasPackageScript_Bad_NoFile(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
if hasPackageScript(io.Local, tmpDir, "test") {
|
if hasPackageScript(io.Local, tmpDir, "test") {
|
||||||
|
|
@ -254,25 +255,25 @@ func TestHasPackageScript_NoFile_Bad(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasPackageScript_InvalidJSON_Bad(t *testing.T) {
|
func TestHasPackageScript_Bad_InvalidJSON(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `invalid json`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`invalid json`), 0644)
|
||||||
|
|
||||||
if hasPackageScript(io.Local, tmpDir, "test") {
|
if hasPackageScript(io.Local, tmpDir, "test") {
|
||||||
t.Error("expected false for invalid JSON")
|
t.Error("expected false for invalid JSON")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasPackageScript_NoScripts_Bad(t *testing.T) {
|
func TestHasPackageScript_Bad_NoScripts(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"name":"test"}`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644)
|
||||||
|
|
||||||
if hasPackageScript(io.Local, tmpDir, "test") {
|
if hasPackageScript(io.Local, tmpDir, "test") {
|
||||||
t.Error("expected false for missing scripts section")
|
t.Error("expected false for missing scripts section")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasComposerScript_NoFile_Bad(t *testing.T) {
|
func TestHasComposerScript_Bad_NoFile(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
if hasComposerScript(io.Local, tmpDir, "test") {
|
if hasComposerScript(io.Local, tmpDir, "test") {
|
||||||
|
|
@ -280,29 +281,29 @@ func TestHasComposerScript_NoFile_Bad(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasComposerScript_InvalidJSON_Bad(t *testing.T) {
|
func TestHasComposerScript_Bad_InvalidJSON(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `invalid json`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`invalid json`), 0644)
|
||||||
|
|
||||||
if hasComposerScript(io.Local, tmpDir, "test") {
|
if hasComposerScript(io.Local, tmpDir, "test") {
|
||||||
t.Error("expected false for invalid JSON")
|
t.Error("expected false for invalid JSON")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasComposerScript_NoScripts_Bad(t *testing.T) {
|
func TestHasComposerScript_Bad_NoScripts(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"name":"test/pkg"}`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644)
|
||||||
|
|
||||||
if hasComposerScript(io.Local, tmpDir, "test") {
|
if hasComposerScript(io.Local, tmpDir, "test") {
|
||||||
t.Error("expected false for missing scripts section")
|
t.Error("expected false for missing scripts section")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadTestConfig_InvalidYAML_Bad(t *testing.T) {
|
func TestLoadTestConfig_Bad_InvalidYAML(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
coreDir := coreutil.JoinPath(tmpDir, ".core")
|
coreDir := filepath.Join(tmpDir, ".core")
|
||||||
_ = io.Local.EnsureDir(coreDir)
|
_ = os.MkdirAll(coreDir, 0755)
|
||||||
_ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "invalid: yaml: :")
|
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("invalid: yaml: :"), 0644)
|
||||||
|
|
||||||
_, err := LoadTestConfig(io.Local, tmpDir)
|
_, err := LoadTestConfig(io.Local, tmpDir)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -310,11 +311,11 @@ func TestLoadTestConfig_InvalidYAML_Bad(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadTestConfig_MinimalConfig_Good(t *testing.T) {
|
func TestLoadTestConfig_Good_MinimalConfig(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
coreDir := coreutil.JoinPath(tmpDir, ".core")
|
coreDir := filepath.Join(tmpDir, ".core")
|
||||||
_ = io.Local.EnsureDir(coreDir)
|
_ = os.MkdirAll(coreDir, 0755)
|
||||||
_ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "version: 1")
|
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("version: 1"), 0644)
|
||||||
|
|
||||||
cfg, err := LoadTestConfig(io.Local, tmpDir)
|
cfg, err := LoadTestConfig(io.Local, tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -328,10 +329,10 @@ func TestLoadTestConfig_MinimalConfig_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_ComposerWithoutScript_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_ComposerWithoutScript(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
// composer.json without test script should not return composer test
|
// composer.json without test script should not return composer test
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"name":"test/pkg"}`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
// Falls through to empty (no match)
|
// Falls through to empty (no match)
|
||||||
|
|
@ -340,10 +341,10 @@ func TestDetectTestCommand_ComposerWithoutScript_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectTestCommand_PackageJSONWithoutScript_Good(t *testing.T) {
|
func TestDetectTestCommand_Good_PackageJSONWithoutScript(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
// package.json without test or dev script
|
// package.json without test or dev script
|
||||||
_ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"name":"test"}`)
|
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644)
|
||||||
|
|
||||||
cmd := DetectTestCommand(io.Local, tmpDir)
|
cmd := DetectTestCommand(io.Local, tmpDir)
|
||||||
// Falls through to empty
|
// Falls through to empty
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ description: How to build, test, and contribute to go-container.
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Go 1.26+** -- The module uses Go 1.26 features.
|
- **Go 1.26+** -- The module uses Go 1.26 features.
|
||||||
- **Go workspace** -- This module is part of a Go workspace at `~/Code/go.work`. Local development of sibling modules (core/io, config, core/i18n, cli) requires the workspace file.
|
- **Go workspace** -- This module is part of a Go workspace at `~/Code/go.work`. Local development of sibling modules (go-io, config, go-i18n, cli) requires the workspace file.
|
||||||
|
|
||||||
Optional (for actually running VMs):
|
Optional (for actually running VMs):
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ Tests use `testify` for assertions. Most tests are self-contained and do not req
|
||||||
|
|
||||||
## Test naming convention
|
## Test naming convention
|
||||||
|
|
||||||
Tests follow the `TestSubject_Function_{Good,Bad,Ugly}` pattern:
|
Tests follow a `_Good`, `_Bad`, `_Ugly` suffix pattern:
|
||||||
|
|
||||||
| Suffix | Meaning |
|
| Suffix | Meaning |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
|
|
@ -51,9 +51,9 @@ Tests follow the `TestSubject_Function_{Good,Bad,Ugly}` pattern:
|
||||||
Examples from the codebase:
|
Examples from the codebase:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestState_NewState_Good(t *testing.T) { /* creates state successfully */ }
|
func TestNewState_Good(t *testing.T) { /* creates state successfully */ }
|
||||||
func TestLoadState_InvalidJSON_Bad(t *testing.T) { /* handles corrupt state file */ }
|
func TestLoadState_Bad_InvalidJSON(t *testing.T) { /* handles corrupt state file */ }
|
||||||
func TestGetHypervisor_Unknown_Bad(t *testing.T) { /* rejects unknown hypervisor name */ }
|
func TestGetHypervisor_Bad_Unknown(t *testing.T) { /* rejects unknown hypervisor name */ }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,8 +108,8 @@ go-container/
|
||||||
|
|
||||||
- **UK English** in all strings, comments, and documentation (colour, organisation, honour).
|
- **UK English** in all strings, comments, and documentation (colour, organisation, honour).
|
||||||
- **Strict typing** -- All function parameters and return values are typed. No `interface{}` without justification.
|
- **Strict typing** -- All function parameters and return values are typed. No `interface{}` without justification.
|
||||||
- **Error wrapping** -- Use `core.E("Op", "message", err)` rather than `fmt.Errorf`.
|
- **Error wrapping** -- Use `fmt.Errorf("context: %w", err)` for all error returns.
|
||||||
- **`io.Medium` abstraction** -- File system operations go through `io.Medium` (from `core/io`) rather than directly calling `os` functions. This enables testing with mock file systems. The `io.Local` singleton is used for real file system access.
|
- **`io.Medium` abstraction** -- File system operations go through `io.Medium` (from `go-io`) rather than directly calling `os` functions. This enables testing with mock file systems. The `io.Local` singleton is used for real file system access.
|
||||||
- **Compile-time interface checks** -- Use `var _ Interface = (*Impl)(nil)` to verify implementations at compile time (see `sources/cdn.go` and `sources/github.go`).
|
- **Compile-time interface checks** -- Use `var _ Interface = (*Impl)(nil)` to verify implementations at compile time (see `sources/cdn.go` and `sources/github.go`).
|
||||||
- **Context propagation** -- All operations that might block accept a `context.Context` as their first parameter.
|
- **Context propagation** -- All operations that might block accept a `context.Context` as their first parameter.
|
||||||
|
|
||||||
|
|
@ -125,14 +125,14 @@ type MyHypervisor struct {
|
||||||
|
|
||||||
func (h *MyHypervisor) Name() string { return "my-hypervisor" }
|
func (h *MyHypervisor) Name() string { return "my-hypervisor" }
|
||||||
func (h *MyHypervisor) Available() bool { /* check if binary exists */ }
|
func (h *MyHypervisor) Available() bool { /* check if binary exists */ }
|
||||||
func (h *MyHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) {
|
func (h *MyHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) {
|
||||||
// Build and return the command.
|
// Build and return exec.Cmd
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Register it in `DetectHypervisor()` and `GetHypervisor()` in `hypervisor.go`.
|
2. Register it in `DetectHypervisor()` and `GetHypervisor()` in `hypervisor.go`.
|
||||||
|
|
||||||
3. Add tests following the `TestSubject_Function_{Good,Bad,Ugly}` naming convention.
|
3. Add tests following the `_Good`/`_Bad` naming convention.
|
||||||
|
|
||||||
|
|
||||||
## Adding a new image source
|
## Adding a new image source
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ description: Container runtime, LinuxKit image builder, and portable development
|
||||||
|
|
||||||
# go-container
|
# go-container
|
||||||
|
|
||||||
`dappco.re/go/core/container` provides a container runtime built on LinuxKit and lightweight hypervisors. It manages the full lifecycle of LinuxKit virtual machines -- from building images with embedded templates, to running them via QEMU or Hyperkit, to offering a portable development environment with shell access, project mounting, test execution, and Claude AI integration.
|
`forge.lthn.ai/core/go-container` provides a container runtime built on LinuxKit and lightweight hypervisors. It manages the full lifecycle of LinuxKit virtual machines -- from building images with embedded templates, to running them via QEMU or Hyperkit, to offering a portable development environment with shell access, project mounting, test execution, and Claude AI integration.
|
||||||
|
|
||||||
This is **not** a Docker wrapper. It runs real VMs from LinuxKit images (ISO, qcow2, VMDK, raw) using platform-native acceleration (KVM on Linux, HVF on macOS, Hyperkit where available).
|
This is **not** a Docker wrapper. It runs real VMs from LinuxKit images (ISO, qcow2, VMDK, raw) using platform-native acceleration (KVM on Linux, HVF on macOS, Hyperkit where available).
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ This is **not** a Docker wrapper. It runs real VMs from LinuxKit images (ISO, qc
|
||||||
## Module path
|
## Module path
|
||||||
|
|
||||||
```
|
```
|
||||||
dappco.re/go/core/container
|
forge.lthn.ai/core/go-container
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires **Go 1.26+**.
|
Requires **Go 1.26+**.
|
||||||
|
|
@ -26,8 +26,8 @@ Requires **Go 1.26+**.
|
||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
container "dappco.re/go/core/container"
|
container "forge.lthn.ai/core/go-container"
|
||||||
"dappco.re/go/core/io"
|
"forge.lthn.ai/core/go-io"
|
||||||
)
|
)
|
||||||
|
|
||||||
manager, err := container.NewLinuxKitManager(io.Local)
|
manager, err := container.NewLinuxKitManager(io.Local)
|
||||||
|
|
@ -54,8 +54,8 @@ fmt.Printf("Started container %s (PID %d)\n", c.ID, c.PID)
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
"dappco.re/go/core/container/devenv"
|
"forge.lthn.ai/core/go-container/devenv"
|
||||||
"dappco.re/go/core/io"
|
"forge.lthn.ai/core/go-io"
|
||||||
)
|
)
|
||||||
|
|
||||||
dev, err := devenv.New(io.Local)
|
dev, err := devenv.New(io.Local)
|
||||||
|
|
@ -77,7 +77,7 @@ err = dev.Test(ctx, "/path/to/project", devenv.TestOptions{})
|
||||||
### Build and run from a LinuxKit template
|
### Build and run from a LinuxKit template
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import container "dappco.re/go/core/container"
|
import container "forge.lthn.ai/core/go-container"
|
||||||
|
|
||||||
// List available templates (built-in + user-defined)
|
// List available templates (built-in + user-defined)
|
||||||
templates := container.ListTemplates()
|
templates := container.ListTemplates()
|
||||||
|
|
@ -95,24 +95,24 @@ content, err := container.ApplyTemplate("core-dev", map[string]string{
|
||||||
|
|
||||||
| Package | Import path | Purpose |
|
| Package | Import path | Purpose |
|
||||||
|---------|-------------|---------|
|
|---------|-------------|---------|
|
||||||
| `container` (root) | `dappco.re/go/core/container` | Container struct, Manager interface, hypervisor abstraction, LinuxKit manager, state persistence, template engine |
|
| `container` (root) | `forge.lthn.ai/core/go-container` | Container struct, Manager interface, hypervisor abstraction, LinuxKit manager, state persistence, template engine |
|
||||||
| `devenv` | `dappco.re/go/core/container/devenv` | Portable dev environment orchestration: boot, shell, serve, test, Claude sandbox, image management |
|
| `devenv` | `forge.lthn.ai/core/go-container/devenv` | Portable dev environment orchestration: boot, shell, serve, test, Claude sandbox, image management |
|
||||||
| `sources` | `dappco.re/go/core/container/sources` | Image download backends: CDN and GitHub Releases with progress reporting |
|
| `sources` | `forge.lthn.ai/core/go-container/sources` | Image download backends: CDN and GitHub Releases with progress reporting |
|
||||||
| `cmd/vm` | `dappco.re/go/core/container/cmd/vm` | CLI commands (`core vm run`, `core vm ps`, `core vm stop`, `core vm logs`, `core vm exec`, `core vm templates`) |
|
| `cmd/vm` | `forge.lthn.ai/core/go-container/cmd/vm` | CLI commands (`core vm run`, `core vm ps`, `core vm stop`, `core vm logs`, `core vm exec`, `core vm templates`) |
|
||||||
|
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
| Module | Purpose |
|
| Module | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `dappco.re/go/core/io` | File system abstraction (`Medium` interface), process utilities |
|
| `forge.lthn.ai/core/go-io` | File system abstraction (`Medium` interface), process utilities |
|
||||||
| `forge.lthn.ai/core/config` | Configuration loading (used by `devenv` for `~/.core/config.yaml`) |
|
| `forge.lthn.ai/core/config` | Configuration loading (used by `devenv` for `~/.core/config.yaml`) |
|
||||||
| `dappco.re/go/core/i18n` | Internationalised UI strings (used by `cmd/vm`) |
|
| `forge.lthn.ai/core/go-i18n` | Internationalised UI strings (used by `cmd/vm`) |
|
||||||
| `forge.lthn.ai/core/cli` | CLI framework (used by `cmd/vm` for command registration) |
|
| `forge.lthn.ai/core/cli` | CLI framework (used by `cmd/vm` for command registration) |
|
||||||
| `github.com/stretchr/testify` | Test assertions |
|
| `github.com/stretchr/testify` | Test assertions |
|
||||||
| `gopkg.in/yaml.v3` | YAML parsing for test configuration |
|
| `gopkg.in/yaml.v3` | YAML parsing for test configuration |
|
||||||
|
|
||||||
The root `container` package has only two direct dependencies: `core/io` and the standard library. The `devenv` and `cmd/vm` packages pull in the heavier dependencies.
|
The root `container` package has only two direct dependencies: `go-io` and the standard library. The `devenv` and `cmd/vm` packages pull in the heavier dependencies.
|
||||||
|
|
||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
|
|
|
||||||
25
go.mod
25
go.mod
|
|
@ -1,24 +1,23 @@
|
||||||
module dappco.re/go/core/container
|
module forge.lthn.ai/core/go-container
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core v0.8.0-alpha.1
|
forge.lthn.ai/core/cli v0.3.1
|
||||||
dappco.re/go/core/i18n v0.2.0
|
forge.lthn.ai/core/config v0.1.3
|
||||||
dappco.re/go/core/io v0.2.0
|
forge.lthn.ai/core/go-i18n v0.1.4
|
||||||
dappco.re/go/core/log v0.1.0
|
forge.lthn.ai/core/go-io v0.1.2
|
||||||
forge.lthn.ai/core/cli v0.3.7
|
forge.lthn.ai/core/go-log v0.0.4
|
||||||
forge.lthn.ai/core/config v0.1.8
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/go v0.3.3 // indirect
|
forge.lthn.ai/core/go v0.3.1 // indirect
|
||||||
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
|
forge.lthn.ai/core/go-crypt v0.1.7 // indirect
|
||||||
forge.lthn.ai/core/go-inference v0.1.6 // indirect
|
forge.lthn.ai/core/go-inference v0.1.4 // indirect
|
||||||
forge.lthn.ai/core/go-io v0.1.7 // indirect
|
forge.lthn.ai/core/go-process v0.2.3 // indirect
|
||||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
github.com/ProtonMail/go-crypto v1.4.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
|
|
@ -28,6 +27,7 @@ require (
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
|
@ -52,6 +52,7 @@ require (
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/term v0.41.0 // indirect
|
golang.org/x/term v0.41.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
|
|
||||||
42
go.sum
42
go.sum
|
|
@ -1,25 +1,23 @@
|
||||||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
forge.lthn.ai/core/cli v0.3.1 h1:ZpHhaDrdbaV98JDxj/f0E5nytYk9tTMRu3qohGyK4M0=
|
||||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
forge.lthn.ai/core/cli v0.3.1/go.mod h1:28cOl9eK0H033Otkjrv9f/QCmtHcJl+IIx4om8JskOg=
|
||||||
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
|
forge.lthn.ai/core/config v0.1.3 h1:mq02v7LFf9jHSqJakO08qYQnPP8oVMbJHlOxNARXBa8=
|
||||||
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
|
forge.lthn.ai/core/config v0.1.3/go.mod h1:4+/ytojOSaPoAQ1uub1+GeOM8OoYdR9xqMtVA3SZ8Qk=
|
||||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
|
||||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
|
||||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
forge.lthn.ai/core/go-crypt v0.1.7 h1:tyDFnXjEksHFQpkFwCpEn+x7zvwh4LnaU+/fP3WmqZc=
|
||||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
forge.lthn.ai/core/go-crypt v0.1.7/go.mod h1:mQdr6K8lWOcyHmSEW24vZPTThQF8fteVgZi8CO+Ko3Y=
|
||||||
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
|
forge.lthn.ai/core/go-i18n v0.1.4 h1:zOHUUJDgRo88/3tj++kN+VELg/buyZ4T2OSdG3HBbLQ=
|
||||||
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
|
forge.lthn.ai/core/go-i18n v0.1.4/go.mod h1:aDyAfz7MMgWYgLkZCptfFmZ7jJg3ocwjEJ1WkJSvv4U=
|
||||||
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=
|
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
|
||||||
forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ=
|
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||||
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
forge.lthn.ai/core/go-io v0.1.2 h1:q8hj2jtOFqAgHlBr5wsUAOXtaFkxy9gqGrQT/il0WYA=
|
||||||
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
|
forge.lthn.ai/core/go-io v0.1.2/go.mod h1:PbNKW1Q25ywSOoQXeGdQHbV5aiIrTXvHIQ5uhplA//g=
|
||||||
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
|
|
||||||
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
|
||||||
forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E=
|
|
||||||
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
|
||||||
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
|
|
||||||
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
|
|
||||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||||
|
forge.lthn.ai/core/go-process v0.2.3 h1:/ERqRYHgCNZjNT9NMinAAJJGJWSsHuCTiHFNEm6nTPY=
|
||||||
|
forge.lthn.ai/core/go-process v0.2.3/go.mod h1:gVTbxL16ccUIexlFcyDtCy7LfYvD8Rtyzfo8bnXAXrU=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
|
@ -38,6 +36,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|
@ -101,6 +101,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreio "dappco.re/go/core/io"
|
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Hypervisor defines the interface for VM hypervisors.
|
// Hypervisor defines the interface for VM hypervisors.
|
||||||
|
|
@ -18,7 +19,7 @@ type Hypervisor interface {
|
||||||
// Available checks if the hypervisor is available on the system.
|
// Available checks if the hypervisor is available on the system.
|
||||||
Available() bool
|
Available() bool
|
||||||
// BuildCommand builds the command to run a VM with the given options.
|
// BuildCommand builds the command to run a VM with the given options.
|
||||||
BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error)
|
BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HypervisorOptions contains options for running a VM.
|
// HypervisorOptions contains options for running a VM.
|
||||||
|
|
@ -46,10 +47,6 @@ type QemuHypervisor struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQemuHypervisor creates a new QEMU hypervisor instance.
|
// NewQemuHypervisor creates a new QEMU hypervisor instance.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// hv := NewQemuHypervisor()
|
|
||||||
func NewQemuHypervisor() *QemuHypervisor {
|
func NewQemuHypervisor() *QemuHypervisor {
|
||||||
return &QemuHypervisor{
|
return &QemuHypervisor{
|
||||||
Binary: "qemu-system-x86_64",
|
Binary: "qemu-system-x86_64",
|
||||||
|
|
@ -63,20 +60,20 @@ func (q *QemuHypervisor) Name() string {
|
||||||
|
|
||||||
// Available checks if QEMU is installed and accessible.
|
// Available checks if QEMU is installed and accessible.
|
||||||
func (q *QemuHypervisor) Available() bool {
|
func (q *QemuHypervisor) Available() bool {
|
||||||
_, err := proc.LookPath(q.Binary)
|
_, err := exec.LookPath(q.Binary)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildCommand creates the QEMU command for running a VM.
|
// BuildCommand creates the QEMU command for running a VM.
|
||||||
func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) {
|
func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) {
|
||||||
format := DetectImageFormat(image)
|
format := DetectImageFormat(image)
|
||||||
if format == FormatUnknown {
|
if format == FormatUnknown {
|
||||||
return nil, coreerr.E("QemuHypervisor.BuildCommand", "unknown image format: "+image, nil)
|
return nil, coreerr.E("QemuHypervisor.BuildCommand", "unknown image format: "+image, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"-m", core.Sprintf("%d", opts.Memory),
|
"-m", fmt.Sprintf("%d", opts.Memory),
|
||||||
"-smp", core.Sprintf("%d", opts.CPUs),
|
"-smp", fmt.Sprintf("%d", opts.CPUs),
|
||||||
"-enable-kvm",
|
"-enable-kvm",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,11 +83,11 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H
|
||||||
args = append(args, "-cdrom", image)
|
args = append(args, "-cdrom", image)
|
||||||
args = append(args, "-boot", "d")
|
args = append(args, "-boot", "d")
|
||||||
case FormatQCOW2:
|
case FormatQCOW2:
|
||||||
args = append(args, "-drive", core.Sprintf("file=%s,format=qcow2", image))
|
args = append(args, "-drive", fmt.Sprintf("file=%s,format=qcow2", image))
|
||||||
case FormatVMDK:
|
case FormatVMDK:
|
||||||
args = append(args, "-drive", core.Sprintf("file=%s,format=vmdk", image))
|
args = append(args, "-drive", fmt.Sprintf("file=%s,format=vmdk", image))
|
||||||
case FormatRaw:
|
case FormatRaw:
|
||||||
args = append(args, "-drive", core.Sprintf("file=%s,format=raw", image))
|
args = append(args, "-drive", fmt.Sprintf("file=%s,format=raw", image))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always run in nographic mode for container-like behavior
|
// Always run in nographic mode for container-like behavior
|
||||||
|
|
@ -102,10 +99,10 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H
|
||||||
// Network with port forwarding
|
// Network with port forwarding
|
||||||
netdev := "user,id=net0"
|
netdev := "user,id=net0"
|
||||||
if opts.SSHPort > 0 {
|
if opts.SSHPort > 0 {
|
||||||
netdev += core.Sprintf(",hostfwd=tcp::%d-:22", opts.SSHPort)
|
netdev += fmt.Sprintf(",hostfwd=tcp::%d-:22", opts.SSHPort)
|
||||||
}
|
}
|
||||||
for hostPort, guestPort := range opts.Ports {
|
for hostPort, guestPort := range opts.Ports {
|
||||||
netdev += core.Sprintf(",hostfwd=tcp::%d-:%d", hostPort, guestPort)
|
netdev += fmt.Sprintf(",hostfwd=tcp::%d-:%d", hostPort, guestPort)
|
||||||
}
|
}
|
||||||
args = append(args, "-netdev", netdev)
|
args = append(args, "-netdev", netdev)
|
||||||
args = append(args, "-device", "virtio-net-pci,netdev=net0")
|
args = append(args, "-device", "virtio-net-pci,netdev=net0")
|
||||||
|
|
@ -113,10 +110,10 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H
|
||||||
// Add 9p shares for volumes
|
// Add 9p shares for volumes
|
||||||
shareID := 0
|
shareID := 0
|
||||||
for hostPath, guestPath := range opts.Volumes {
|
for hostPath, guestPath := range opts.Volumes {
|
||||||
tag := core.Sprintf("share%d", shareID)
|
tag := fmt.Sprintf("share%d", shareID)
|
||||||
args = append(args,
|
args = append(args,
|
||||||
"-fsdev", core.Sprintf("local,id=%s,path=%s,security_model=none", tag, hostPath),
|
"-fsdev", fmt.Sprintf("local,id=%s,path=%s,security_model=none", tag, hostPath),
|
||||||
"-device", core.Sprintf("virtio-9p-pci,fsdev=%s,mount_tag=%s", tag, core.PathBase(guestPath)),
|
"-device", fmt.Sprintf("virtio-9p-pci,fsdev=%s,mount_tag=%s", tag, filepath.Base(guestPath)),
|
||||||
)
|
)
|
||||||
shareID++
|
shareID++
|
||||||
}
|
}
|
||||||
|
|
@ -138,12 +135,14 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return proc.NewCommandContext(ctx, q.Binary, args...), nil
|
cmd := exec.CommandContext(ctx, q.Binary, args...)
|
||||||
|
return cmd, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isKVMAvailable checks if KVM is available on the system.
|
// isKVMAvailable checks if KVM is available on the system.
|
||||||
func isKVMAvailable() bool {
|
func isKVMAvailable() bool {
|
||||||
return coreio.Local.Exists("/dev/kvm")
|
_, err := os.Stat("/dev/kvm")
|
||||||
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HyperkitHypervisor implements Hypervisor for macOS Hyperkit.
|
// HyperkitHypervisor implements Hypervisor for macOS Hyperkit.
|
||||||
|
|
@ -153,10 +152,6 @@ type HyperkitHypervisor struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHyperkitHypervisor creates a new Hyperkit hypervisor instance.
|
// NewHyperkitHypervisor creates a new Hyperkit hypervisor instance.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// hv := NewHyperkitHypervisor()
|
|
||||||
func NewHyperkitHypervisor() *HyperkitHypervisor {
|
func NewHyperkitHypervisor() *HyperkitHypervisor {
|
||||||
return &HyperkitHypervisor{
|
return &HyperkitHypervisor{
|
||||||
Binary: "hyperkit",
|
Binary: "hyperkit",
|
||||||
|
|
@ -173,20 +168,20 @@ func (h *HyperkitHypervisor) Available() bool {
|
||||||
if runtime.GOOS != "darwin" {
|
if runtime.GOOS != "darwin" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
_, err := proc.LookPath(h.Binary)
|
_, err := exec.LookPath(h.Binary)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildCommand creates the Hyperkit command for running a VM.
|
// BuildCommand creates the Hyperkit command for running a VM.
|
||||||
func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) {
|
func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) {
|
||||||
format := DetectImageFormat(image)
|
format := DetectImageFormat(image)
|
||||||
if format == FormatUnknown {
|
if format == FormatUnknown {
|
||||||
return nil, coreerr.E("HyperkitHypervisor.BuildCommand", "unknown image format: "+image, nil)
|
return nil, coreerr.E("HyperkitHypervisor.BuildCommand", "unknown image format: "+image, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"-m", core.Sprintf("%dM", opts.Memory),
|
"-m", fmt.Sprintf("%dM", opts.Memory),
|
||||||
"-c", core.Sprintf("%d", opts.CPUs),
|
"-c", fmt.Sprintf("%d", opts.CPUs),
|
||||||
"-A", // ACPI
|
"-A", // ACPI
|
||||||
"-u", // Unlimited console output
|
"-u", // Unlimited console output
|
||||||
"-s", "0:0,hostbridge",
|
"-s", "0:0,hostbridge",
|
||||||
|
|
@ -197,9 +192,9 @@ func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opt
|
||||||
// Add PCI slot for disk (slot 2)
|
// Add PCI slot for disk (slot 2)
|
||||||
switch format {
|
switch format {
|
||||||
case FormatISO:
|
case FormatISO:
|
||||||
args = append(args, "-s", core.Sprintf("2:0,ahci-cd,%s", image))
|
args = append(args, "-s", fmt.Sprintf("2:0,ahci-cd,%s", image))
|
||||||
case FormatQCOW2, FormatVMDK, FormatRaw:
|
case FormatQCOW2, FormatVMDK, FormatRaw:
|
||||||
args = append(args, "-s", core.Sprintf("2:0,virtio-blk,%s", image))
|
args = append(args, "-s", fmt.Sprintf("2:0,virtio-blk,%s", image))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network with port forwarding (slot 3)
|
// Network with port forwarding (slot 3)
|
||||||
|
|
@ -208,27 +203,24 @@ func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opt
|
||||||
// Hyperkit uses slirp for user networking with port forwarding
|
// Hyperkit uses slirp for user networking with port forwarding
|
||||||
portForwards := make([]string, 0)
|
portForwards := make([]string, 0)
|
||||||
if opts.SSHPort > 0 {
|
if opts.SSHPort > 0 {
|
||||||
portForwards = append(portForwards, core.Sprintf("tcp:%d:22", opts.SSHPort))
|
portForwards = append(portForwards, fmt.Sprintf("tcp:%d:22", opts.SSHPort))
|
||||||
}
|
}
|
||||||
for hostPort, guestPort := range opts.Ports {
|
for hostPort, guestPort := range opts.Ports {
|
||||||
portForwards = append(portForwards, core.Sprintf("tcp:%d:%d", hostPort, guestPort))
|
portForwards = append(portForwards, fmt.Sprintf("tcp:%d:%d", hostPort, guestPort))
|
||||||
}
|
}
|
||||||
if len(portForwards) > 0 {
|
if len(portForwards) > 0 {
|
||||||
netArgs += "," + core.Join(",", portForwards...)
|
netArgs += "," + strings.Join(portForwards, ",")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
args = append(args, "-s", "3:0,"+netArgs)
|
args = append(args, "-s", "3:0,"+netArgs)
|
||||||
|
|
||||||
return proc.NewCommandContext(ctx, h.Binary, args...), nil
|
cmd := exec.CommandContext(ctx, h.Binary, args...)
|
||||||
|
return cmd, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectImageFormat determines the image format from its file extension.
|
// DetectImageFormat determines the image format from its file extension.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// format := DetectImageFormat("/tmp/core-dev.qcow2")
|
|
||||||
func DetectImageFormat(path string) ImageFormat {
|
func DetectImageFormat(path string) ImageFormat {
|
||||||
ext := core.Lower(core.PathExt(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".iso":
|
case ".iso":
|
||||||
return FormatISO
|
return FormatISO
|
||||||
|
|
@ -244,10 +236,6 @@ func DetectImageFormat(path string) ImageFormat {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectHypervisor returns the best available hypervisor for the current platform.
|
// DetectHypervisor returns the best available hypervisor for the current platform.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// hv, err := DetectHypervisor()
|
|
||||||
func DetectHypervisor() (Hypervisor, error) {
|
func DetectHypervisor() (Hypervisor, error) {
|
||||||
// On macOS, prefer Hyperkit if available, fall back to QEMU
|
// On macOS, prefer Hyperkit if available, fall back to QEMU
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
|
|
@ -267,12 +255,8 @@ func DetectHypervisor() (Hypervisor, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHypervisor returns a specific hypervisor by name.
|
// GetHypervisor returns a specific hypervisor by name.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// hv, err := GetHypervisor("qemu")
|
|
||||||
func GetHypervisor(name string) (Hypervisor, error) {
|
func GetHypervisor(name string) (Hypervisor, error) {
|
||||||
switch core.Lower(name) {
|
switch strings.ToLower(name) {
|
||||||
case "qemu":
|
case "qemu":
|
||||||
h := NewQemuHypervisor()
|
h := NewQemuHypervisor()
|
||||||
if !h.Available() {
|
if !h.Available() {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ func TestQemuHypervisor_Available_Good(t *testing.T) {
|
||||||
assert.IsType(t, true, available)
|
assert.IsType(t, true, available)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQemuHypervisor_Available_InvalidBinary_Bad(t *testing.T) {
|
func TestQemuHypervisor_Available_Bad_InvalidBinary(t *testing.T) {
|
||||||
q := &QemuHypervisor{
|
q := &QemuHypervisor{
|
||||||
Binary: "nonexistent-qemu-binary-that-does-not-exist",
|
Binary: "nonexistent-qemu-binary-that-does-not-exist",
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ func TestHyperkitHypervisor_Available_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyperkitHypervisor_Available_NotDarwin_Bad(t *testing.T) {
|
func TestHyperkitHypervisor_Available_Bad_NotDarwin(t *testing.T) {
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
t.Skip("This test only runs on non-darwin systems")
|
t.Skip("This test only runs on non-darwin systems")
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +56,7 @@ func TestHyperkitHypervisor_Available_NotDarwin_Bad(t *testing.T) {
|
||||||
assert.False(t, available, "Hyperkit should not be available on non-darwin systems")
|
assert.False(t, available, "Hyperkit should not be available on non-darwin systems")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyperkitHypervisor_Available_InvalidBinary_Bad(t *testing.T) {
|
func TestHyperkitHypervisor_Available_Bad_InvalidBinary(t *testing.T) {
|
||||||
h := &HyperkitHypervisor{
|
h := &HyperkitHypervisor{
|
||||||
Binary: "nonexistent-hyperkit-binary-that-does-not-exist",
|
Binary: "nonexistent-hyperkit-binary-that-does-not-exist",
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +66,7 @@ func TestHyperkitHypervisor_Available_InvalidBinary_Bad(t *testing.T) {
|
||||||
assert.False(t, available)
|
assert.False(t, available)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHypervisor_IsKVMAvailable_Good(t *testing.T) {
|
func TestIsKVMAvailable_Good(t *testing.T) {
|
||||||
// This test verifies the function runs without error
|
// This test verifies the function runs without error
|
||||||
// The actual result depends on the system
|
// The actual result depends on the system
|
||||||
result := isKVMAvailable()
|
result := isKVMAvailable()
|
||||||
|
|
@ -80,7 +80,7 @@ func TestHypervisor_IsKVMAvailable_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHypervisor_DetectHypervisor_Good(t *testing.T) {
|
func TestDetectHypervisor_Good(t *testing.T) {
|
||||||
// DetectHypervisor tries to find an available hypervisor
|
// DetectHypervisor tries to find an available hypervisor
|
||||||
hv, err := DetectHypervisor()
|
hv, err := DetectHypervisor()
|
||||||
|
|
||||||
|
|
@ -95,7 +95,7 @@ func TestHypervisor_DetectHypervisor_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHypervisor_Qemu_Good(t *testing.T) {
|
func TestGetHypervisor_Good_Qemu(t *testing.T) {
|
||||||
hv, err := GetHypervisor("qemu")
|
hv, err := GetHypervisor("qemu")
|
||||||
|
|
||||||
// Depends on whether qemu is installed
|
// Depends on whether qemu is installed
|
||||||
|
|
@ -107,7 +107,7 @@ func TestGetHypervisor_Qemu_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHypervisor_QemuUppercase_Good(t *testing.T) {
|
func TestGetHypervisor_Good_QemuUppercase(t *testing.T) {
|
||||||
hv, err := GetHypervisor("QEMU")
|
hv, err := GetHypervisor("QEMU")
|
||||||
|
|
||||||
// Depends on whether qemu is installed
|
// Depends on whether qemu is installed
|
||||||
|
|
@ -119,7 +119,7 @@ func TestGetHypervisor_QemuUppercase_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHypervisor_Hyperkit_Good(t *testing.T) {
|
func TestGetHypervisor_Good_Hyperkit(t *testing.T) {
|
||||||
hv, err := GetHypervisor("hyperkit")
|
hv, err := GetHypervisor("hyperkit")
|
||||||
|
|
||||||
// On non-darwin systems, should always fail
|
// On non-darwin systems, should always fail
|
||||||
|
|
@ -137,14 +137,14 @@ func TestGetHypervisor_Hyperkit_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHypervisor_Unknown_Bad(t *testing.T) {
|
func TestGetHypervisor_Bad_Unknown(t *testing.T) {
|
||||||
_, err := GetHypervisor("unknown-hypervisor")
|
_, err := GetHypervisor("unknown-hypervisor")
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "unknown hypervisor")
|
assert.Contains(t, err.Error(), "unknown hypervisor")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQemuHypervisor_BuildCommand_WithPortsAndVolumes_Good(t *testing.T) {
|
func TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes(t *testing.T) {
|
||||||
q := NewQemuHypervisor()
|
q := NewQemuHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -172,7 +172,7 @@ func TestQemuHypervisor_BuildCommand_WithPortsAndVolumes_Good(t *testing.T) {
|
||||||
assert.Contains(t, args, "4")
|
assert.Contains(t, args, "4")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQemuHypervisor_BuildCommand_QCow2Format_Good(t *testing.T) {
|
func TestQemuHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) {
|
||||||
q := NewQemuHypervisor()
|
q := NewQemuHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -192,7 +192,7 @@ func TestQemuHypervisor_BuildCommand_QCow2Format_Good(t *testing.T) {
|
||||||
assert.True(t, found, "Should have qcow2 drive argument")
|
assert.True(t, found, "Should have qcow2 drive argument")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQemuHypervisor_BuildCommand_VMDKFormat_Good(t *testing.T) {
|
func TestQemuHypervisor_BuildCommand_Good_VMDKFormat(t *testing.T) {
|
||||||
q := NewQemuHypervisor()
|
q := NewQemuHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -212,7 +212,7 @@ func TestQemuHypervisor_BuildCommand_VMDKFormat_Good(t *testing.T) {
|
||||||
assert.True(t, found, "Should have vmdk drive argument")
|
assert.True(t, found, "Should have vmdk drive argument")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQemuHypervisor_BuildCommand_RawFormat_Good(t *testing.T) {
|
func TestQemuHypervisor_BuildCommand_Good_RawFormat(t *testing.T) {
|
||||||
q := NewQemuHypervisor()
|
q := NewQemuHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -232,7 +232,7 @@ func TestQemuHypervisor_BuildCommand_RawFormat_Good(t *testing.T) {
|
||||||
assert.True(t, found, "Should have raw drive argument")
|
assert.True(t, found, "Should have raw drive argument")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyperkitHypervisor_BuildCommand_WithPorts_Good(t *testing.T) {
|
func TestHyperkitHypervisor_BuildCommand_Good_WithPorts(t *testing.T) {
|
||||||
h := NewHyperkitHypervisor()
|
h := NewHyperkitHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -255,7 +255,7 @@ func TestHyperkitHypervisor_BuildCommand_WithPorts_Good(t *testing.T) {
|
||||||
assert.Contains(t, args, "2")
|
assert.Contains(t, args, "2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyperkitHypervisor_BuildCommand_QCow2Format_Good(t *testing.T) {
|
func TestHyperkitHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) {
|
||||||
h := NewHyperkitHypervisor()
|
h := NewHyperkitHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -266,7 +266,7 @@ func TestHyperkitHypervisor_BuildCommand_QCow2Format_Good(t *testing.T) {
|
||||||
assert.NotNil(t, cmd)
|
assert.NotNil(t, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyperkitHypervisor_BuildCommand_RawFormat_Good(t *testing.T) {
|
func TestHyperkitHypervisor_BuildCommand_Good_RawFormat(t *testing.T) {
|
||||||
h := NewHyperkitHypervisor()
|
h := NewHyperkitHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -277,7 +277,7 @@ func TestHyperkitHypervisor_BuildCommand_RawFormat_Good(t *testing.T) {
|
||||||
assert.NotNil(t, cmd)
|
assert.NotNil(t, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyperkitHypervisor_BuildCommand_NoPorts_Good(t *testing.T) {
|
func TestHyperkitHypervisor_BuildCommand_Good_NoPorts(t *testing.T) {
|
||||||
h := NewHyperkitHypervisor()
|
h := NewHyperkitHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -293,7 +293,7 @@ func TestHyperkitHypervisor_BuildCommand_NoPorts_Good(t *testing.T) {
|
||||||
assert.NotNil(t, cmd)
|
assert.NotNil(t, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQemuHypervisor_BuildCommand_NoSSHPort_Good(t *testing.T) {
|
func TestQemuHypervisor_BuildCommand_Good_NoSSHPort(t *testing.T) {
|
||||||
q := NewQemuHypervisor()
|
q := NewQemuHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -309,7 +309,7 @@ func TestQemuHypervisor_BuildCommand_NoSSHPort_Good(t *testing.T) {
|
||||||
assert.NotNil(t, cmd)
|
assert.NotNil(t, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQemuHypervisor_BuildCommand_UnknownFormat_Bad(t *testing.T) {
|
func TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) {
|
||||||
q := NewQemuHypervisor()
|
q := NewQemuHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -320,7 +320,7 @@ func TestQemuHypervisor_BuildCommand_UnknownFormat_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "unknown image format")
|
assert.Contains(t, err.Error(), "unknown image format")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyperkitHypervisor_BuildCommand_UnknownFormat_Bad(t *testing.T) {
|
func TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) {
|
||||||
h := NewHyperkitHypervisor()
|
h := NewHyperkitHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -336,7 +336,7 @@ func TestHyperkitHypervisor_Name_Good(t *testing.T) {
|
||||||
assert.Equal(t, "hyperkit", h.Name())
|
assert.Equal(t, "hyperkit", h.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyperkitHypervisor_BuildCommand_ISOFormat_Good(t *testing.T) {
|
func TestHyperkitHypervisor_BuildCommand_Good_ISOFormat(t *testing.T) {
|
||||||
h := NewHyperkitHypervisor()
|
h := NewHyperkitHypervisor()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
package coreutil
|
|
||||||
|
|
||||||
import (
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
coreio "dappco.re/go/core/io"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DirSep returns the active directory separator.
|
|
||||||
func DirSep() string {
|
|
||||||
if ds := core.Env("DS"); ds != "" {
|
|
||||||
return ds
|
|
||||||
}
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
// JoinPath joins path segments using the active directory separator.
|
|
||||||
func JoinPath(parts ...string) string {
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return core.CleanPath(core.Join(DirSep(), parts...), DirSep())
|
|
||||||
}
|
|
||||||
|
|
||||||
// HomeDir returns the current home directory, honouring test-time env overrides.
|
|
||||||
func HomeDir() string {
|
|
||||||
if home := core.Env("CORE_HOME"); home != "" {
|
|
||||||
return home
|
|
||||||
}
|
|
||||||
if home := core.Env("HOME"); home != "" {
|
|
||||||
return home
|
|
||||||
}
|
|
||||||
if home := core.Env("USERPROFILE"); home != "" {
|
|
||||||
return home
|
|
||||||
}
|
|
||||||
return core.Env("DIR_HOME")
|
|
||||||
}
|
|
||||||
|
|
||||||
// CurrentDir returns the current working directory, honouring shell PWD.
|
|
||||||
func CurrentDir() string {
|
|
||||||
if pwd := core.Env("PWD"); pwd != "" {
|
|
||||||
return pwd
|
|
||||||
}
|
|
||||||
return core.Env("DIR_CWD")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TempDir returns the process temp directory, honouring TMPDIR.
|
|
||||||
func TempDir() string {
|
|
||||||
if dir := core.Env("TMPDIR"); dir != "" {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
return core.Env("DIR_TMP")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AbsPath resolves a path against the current working directory.
|
|
||||||
func AbsPath(path string) string {
|
|
||||||
if path == "" {
|
|
||||||
return CurrentDir()
|
|
||||||
}
|
|
||||||
if core.PathIsAbs(path) {
|
|
||||||
return core.CleanPath(path, DirSep())
|
|
||||||
}
|
|
||||||
return JoinPath(CurrentDir(), path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MkdirTemp creates a temporary directory with a deterministic Core-generated name.
|
|
||||||
func MkdirTemp(prefix string) (string, error) {
|
|
||||||
name := prefix
|
|
||||||
if name == "" {
|
|
||||||
name = "tmp-"
|
|
||||||
}
|
|
||||||
path := JoinPath(TempDir(), core.Concat(name, core.ID()))
|
|
||||||
if err := coreio.Local.EnsureDir(path); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,401 +0,0 @@
|
||||||
package proc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
goio "io"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
coreio "dappco.re/go/core/io"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fdProvider interface {
|
|
||||||
Fd() uintptr
|
|
||||||
}
|
|
||||||
|
|
||||||
type Process struct {
|
|
||||||
Pid int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Process) Kill() error {
|
|
||||||
if p == nil || p.Pid <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return syscall.Kill(p.Pid, syscall.SIGKILL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Process) Signal(sig syscall.Signal) error {
|
|
||||||
if p == nil || p.Pid <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return syscall.Kill(p.Pid, sig)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Command struct {
|
|
||||||
Path string
|
|
||||||
Args []string
|
|
||||||
Dir string
|
|
||||||
Env []string
|
|
||||||
Stdin goio.Reader
|
|
||||||
Stdout goio.Writer
|
|
||||||
Stderr goio.Writer
|
|
||||||
|
|
||||||
Process *Process
|
|
||||||
|
|
||||||
ctx context.Context
|
|
||||||
|
|
||||||
started bool
|
|
||||||
done chan struct{}
|
|
||||||
waitErr error
|
|
||||||
waited bool
|
|
||||||
waitMu sync.Mutex
|
|
||||||
|
|
||||||
stdoutPipe *pipeReader
|
|
||||||
stderrPipe *pipeReader
|
|
||||||
}
|
|
||||||
|
|
||||||
type pipeReader struct {
|
|
||||||
fd int
|
|
||||||
childFD int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *pipeReader) Read(data []byte) (int, error) {
|
|
||||||
n, err := syscall.Read(p.fd, data)
|
|
||||||
if err != nil {
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return 0, goio.EOF
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *pipeReader) Close() error {
|
|
||||||
var first error
|
|
||||||
if p.fd >= 0 {
|
|
||||||
if err := syscall.Close(p.fd); err != nil {
|
|
||||||
first = err
|
|
||||||
}
|
|
||||||
p.fd = -1
|
|
||||||
}
|
|
||||||
if p.childFD >= 0 {
|
|
||||||
if err := syscall.Close(p.childFD); err != nil && first == nil {
|
|
||||||
first = err
|
|
||||||
}
|
|
||||||
p.childFD = -1
|
|
||||||
}
|
|
||||||
return first
|
|
||||||
}
|
|
||||||
|
|
||||||
type stdioReader struct {
|
|
||||||
fd int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stdioReader) Read(data []byte) (int, error) {
|
|
||||||
n, err := syscall.Read(s.fd, data)
|
|
||||||
if err != nil {
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return 0, goio.EOF
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stdioReader) Close() error { return nil }
|
|
||||||
|
|
||||||
func (s *stdioReader) Fd() uintptr { return uintptr(s.fd) }
|
|
||||||
|
|
||||||
type stdioWriter struct {
|
|
||||||
fd int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stdioWriter) Write(data []byte) (int, error) {
|
|
||||||
total := 0
|
|
||||||
for len(data) > 0 {
|
|
||||||
n, err := syscall.Write(s.fd, data)
|
|
||||||
total += n
|
|
||||||
if err != nil {
|
|
||||||
return total, err
|
|
||||||
}
|
|
||||||
data = data[n:]
|
|
||||||
}
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stdioWriter) Close() error { return nil }
|
|
||||||
|
|
||||||
func (s *stdioWriter) Fd() uintptr { return uintptr(s.fd) }
|
|
||||||
|
|
||||||
var (
|
|
||||||
Stdin goio.ReadCloser = &stdioReader{fd: 0}
|
|
||||||
Stdout goio.WriteCloser = &stdioWriter{fd: 1}
|
|
||||||
Stderr goio.WriteCloser = &stdioWriter{fd: 2}
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
nullFD int
|
|
||||||
nullOnce sync.Once
|
|
||||||
nullErr error
|
|
||||||
)
|
|
||||||
|
|
||||||
func Environ() []string {
|
|
||||||
return syscall.Environ()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCommandContext(ctx context.Context, name string, args ...string) *Command {
|
|
||||||
if ctx == nil {
|
|
||||||
ctx = context.Background()
|
|
||||||
}
|
|
||||||
return &Command{
|
|
||||||
Path: name,
|
|
||||||
Args: append([]string{name}, args...),
|
|
||||||
ctx: ctx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCommand(name string, args ...string) *Command {
|
|
||||||
return NewCommandContext(context.Background(), name, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LookPath(name string) (string, error) {
|
|
||||||
if name == "" {
|
|
||||||
return "", core.E("proc.LookPath", "empty command", nil)
|
|
||||||
}
|
|
||||||
if core.Contains(name, "/") || core.Contains(name, "\\") {
|
|
||||||
if isExecutable(name) {
|
|
||||||
return name, nil
|
|
||||||
}
|
|
||||||
return "", core.E("proc.LookPath", core.Concat("executable not found: ", name), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
pathEnv := core.Env("PATH")
|
|
||||||
sep := core.Env("PS")
|
|
||||||
if sep == "" {
|
|
||||||
sep = ":"
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, dir := range core.Split(pathEnv, sep) {
|
|
||||||
if dir == "" {
|
|
||||||
dir = "."
|
|
||||||
}
|
|
||||||
candidate := coreutil.JoinPath(dir, name)
|
|
||||||
if isExecutable(candidate) {
|
|
||||||
return candidate, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", core.E("proc.LookPath", core.Concat("executable not found: ", name), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Command) StdoutPipe() (goio.ReadCloser, error) {
|
|
||||||
if c.started {
|
|
||||||
return nil, core.E("proc.Command.StdoutPipe", "command already started", nil)
|
|
||||||
}
|
|
||||||
if c.stdoutPipe != nil {
|
|
||||||
return nil, core.E("proc.Command.StdoutPipe", "stdout pipe already requested", nil)
|
|
||||||
}
|
|
||||||
fds := make([]int, 2)
|
|
||||||
if err := syscall.Pipe(fds); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.stdoutPipe = &pipeReader{fd: fds[0], childFD: fds[1]}
|
|
||||||
return c.stdoutPipe, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Command) StderrPipe() (goio.ReadCloser, error) {
|
|
||||||
if c.started {
|
|
||||||
return nil, core.E("proc.Command.StderrPipe", "command already started", nil)
|
|
||||||
}
|
|
||||||
if c.stderrPipe != nil {
|
|
||||||
return nil, core.E("proc.Command.StderrPipe", "stderr pipe already requested", nil)
|
|
||||||
}
|
|
||||||
fds := make([]int, 2)
|
|
||||||
if err := syscall.Pipe(fds); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.stderrPipe = &pipeReader{fd: fds[0], childFD: fds[1]}
|
|
||||||
return c.stderrPipe, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Command) Start() error {
|
|
||||||
if c.started {
|
|
||||||
return core.E("proc.Command.Start", "command already started", nil)
|
|
||||||
}
|
|
||||||
if c.ctx != nil {
|
|
||||||
if err := c.ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
path, err := LookPath(c.Path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
files := []uintptr{
|
|
||||||
c.inputFD(),
|
|
||||||
c.outputFD(c.stdoutPipe, c.Stdout),
|
|
||||||
c.outputFD(c.stderrPipe, c.Stderr),
|
|
||||||
}
|
|
||||||
|
|
||||||
env := c.Env
|
|
||||||
if env == nil {
|
|
||||||
env = Environ()
|
|
||||||
}
|
|
||||||
|
|
||||||
pid, _, err := syscall.StartProcess(path, c.Args, &syscall.ProcAttr{
|
|
||||||
Dir: c.Dir,
|
|
||||||
Env: env,
|
|
||||||
Files: files,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Process = &Process{Pid: pid}
|
|
||||||
c.done = make(chan struct{})
|
|
||||||
c.started = true
|
|
||||||
c.closeChildPipeEnds()
|
|
||||||
c.watchContext()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Command) Run() error {
|
|
||||||
if err := c.Start(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Command) Output() ([]byte, error) {
|
|
||||||
if c.Stdout != nil {
|
|
||||||
return nil, core.E("proc.Command.Output", "stdout already configured", nil)
|
|
||||||
}
|
|
||||||
reader, err := c.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
if err := c.Start(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, readErr := goio.ReadAll(reader)
|
|
||||||
waitErr := c.Wait()
|
|
||||||
if readErr != nil {
|
|
||||||
return nil, readErr
|
|
||||||
}
|
|
||||||
if waitErr != nil {
|
|
||||||
return data, waitErr
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Command) Wait() error {
|
|
||||||
c.waitMu.Lock()
|
|
||||||
defer c.waitMu.Unlock()
|
|
||||||
|
|
||||||
if !c.started {
|
|
||||||
return core.E("proc.Command.Wait", "command not started", nil)
|
|
||||||
}
|
|
||||||
if c.waited {
|
|
||||||
return c.waitErr
|
|
||||||
}
|
|
||||||
|
|
||||||
var status syscall.WaitStatus
|
|
||||||
for {
|
|
||||||
_, err := syscall.Wait4(c.Process.Pid, &status, 0, nil)
|
|
||||||
if err == syscall.EINTR {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
c.waitErr = err
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if status.Exited() && status.ExitStatus() != 0 {
|
|
||||||
c.waitErr = core.E("proc.Command.Wait", core.Sprintf("exit status %d", status.ExitStatus()), nil)
|
|
||||||
}
|
|
||||||
if status.Signaled() {
|
|
||||||
c.waitErr = core.E("proc.Command.Wait", core.Sprintf("signal %d", status.Signal()), nil)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
c.waited = true
|
|
||||||
close(c.done)
|
|
||||||
return c.waitErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Command) inputFD() uintptr {
|
|
||||||
if c.Stdin == nil {
|
|
||||||
return uintptr(openNull())
|
|
||||||
}
|
|
||||||
if file, ok := c.Stdin.(fdProvider); ok {
|
|
||||||
return file.Fd()
|
|
||||||
}
|
|
||||||
return uintptr(openNull())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Command) outputFD(pipe *pipeReader, writer goio.Writer) uintptr {
|
|
||||||
if pipe != nil {
|
|
||||||
return uintptr(pipe.childFD)
|
|
||||||
}
|
|
||||||
if writer == nil {
|
|
||||||
return uintptr(openNull())
|
|
||||||
}
|
|
||||||
if file, ok := writer.(fdProvider); ok {
|
|
||||||
return file.Fd()
|
|
||||||
}
|
|
||||||
return uintptr(openNull())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Command) closeChildPipeEnds() {
|
|
||||||
if c.stdoutPipe != nil && c.stdoutPipe.childFD >= 0 {
|
|
||||||
_ = syscall.Close(c.stdoutPipe.childFD)
|
|
||||||
c.stdoutPipe.childFD = -1
|
|
||||||
}
|
|
||||||
if c.stderrPipe != nil && c.stderrPipe.childFD >= 0 {
|
|
||||||
_ = syscall.Close(c.stderrPipe.childFD)
|
|
||||||
c.stderrPipe.childFD = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Command) watchContext() {
|
|
||||||
if c.ctx == nil || c.done == nil || c.Process == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-c.ctx.Done():
|
|
||||||
_ = c.Process.Kill()
|
|
||||||
case <-c.done:
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func isExecutable(path string) bool {
|
|
||||||
info, err := coreio.Local.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return info.Mode()&0111 != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func openNull() int {
|
|
||||||
nullOnce.Do(func() {
|
|
||||||
nullFD, nullErr = syscall.Open("/dev/null", syscall.O_RDWR, 0)
|
|
||||||
})
|
|
||||||
if nullErr != nil {
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
return nullFD
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# go-container
|
# go-container
|
||||||
|
|
||||||
Module: `dappco.re/go/core/container`
|
Module: `forge.lthn.ai/core/go-container`
|
||||||
|
|
||||||
Container runtime for managing LinuxKit VMs as lightweight containers. Supports running LinuxKit images (ISO, qcow2, vmdk, raw) via QEMU or Hyperkit hypervisors. Includes a dev environment system for Claude Code agents and development workflows.
|
Container runtime for managing LinuxKit VMs as lightweight containers. Supports running LinuxKit images (ISO, qcow2, vmdk, raw) via QEMU or Hyperkit hypervisors. Includes a dev environment system for Claude Code agents and development workflows.
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ Container runtime for managing LinuxKit VMs as lightweight containers. Supports
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "dappco.re/go/core/container"
|
import "forge.lthn.ai/core/go-container"
|
||||||
|
|
||||||
// Auto-detect hypervisor
|
// Auto-detect hypervisor
|
||||||
hv, _ := container.DetectHypervisor()
|
hv, _ := container.DetectHypervisor()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Hypervisors
|
# Hypervisors
|
||||||
|
|
||||||
Module: `dappco.re/go/core/container`
|
Module: `forge.lthn.ai/core/go-container`
|
||||||
|
|
||||||
## Interface
|
## Interface
|
||||||
|
|
||||||
|
|
|
||||||
86
linuxkit.go
86
linuxkit.go
|
|
@ -3,15 +3,15 @@ package container
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
goio "io"
|
goio "io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// LinuxKitManager implements the Manager interface for LinuxKit VMs.
|
// LinuxKitManager implements the Manager interface for LinuxKit VMs.
|
||||||
|
|
@ -22,10 +22,6 @@ type LinuxKitManager struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLinuxKitManager creates a new LinuxKit manager with auto-detected hypervisor.
|
// NewLinuxKitManager creates a new LinuxKit manager with auto-detected hypervisor.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// manager, err := NewLinuxKitManager(io.Local)
|
|
||||||
func NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error) {
|
func NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error) {
|
||||||
statePath, err := DefaultStatePath()
|
statePath, err := DefaultStatePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -50,10 +46,6 @@ func NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLinuxKitManagerWithHypervisor creates a manager with a specific hypervisor.
|
// NewLinuxKitManagerWithHypervisor creates a manager with a specific hypervisor.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// manager := NewLinuxKitManagerWithHypervisor(io.Local, state, hypervisor)
|
|
||||||
func NewLinuxKitManagerWithHypervisor(m io.Medium, state *State, hypervisor Hypervisor) *LinuxKitManager {
|
func NewLinuxKitManagerWithHypervisor(m io.Medium, state *State, hypervisor Hypervisor) *LinuxKitManager {
|
||||||
return &LinuxKitManager{
|
return &LinuxKitManager{
|
||||||
state: state,
|
state: state,
|
||||||
|
|
@ -127,7 +119,7 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create log file
|
// Create log file
|
||||||
logFile, err := io.Local.Create(logPath)
|
logFile, err := os.Create(logPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("LinuxKitManager.Run", "failed to create log file", err)
|
return nil, coreerr.E("LinuxKitManager.Run", "failed to create log file", err)
|
||||||
}
|
}
|
||||||
|
|
@ -204,11 +196,11 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
||||||
|
|
||||||
// Copy output to both log and stdout
|
// Copy output to both log and stdout
|
||||||
go func() {
|
go func() {
|
||||||
mw := goio.MultiWriter(logFile, proc.Stdout)
|
mw := goio.MultiWriter(logFile, os.Stdout)
|
||||||
_, _ = goio.Copy(mw, stdout)
|
_, _ = goio.Copy(mw, stdout)
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
mw := goio.MultiWriter(logFile, proc.Stderr)
|
mw := goio.MultiWriter(logFile, os.Stderr)
|
||||||
_, _ = goio.Copy(mw, stderr)
|
_, _ = goio.Copy(mw, stderr)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -228,7 +220,7 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForExit monitors a detached process and updates state when it exits.
|
// waitForExit monitors a detached process and updates state when it exits.
|
||||||
func (m *LinuxKitManager) waitForExit(id string, cmd *proc.Command) {
|
func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) {
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
|
|
||||||
container, ok := m.state.Get(id)
|
container, ok := m.state.Get(id)
|
||||||
|
|
@ -257,7 +249,16 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the process
|
// Find the process
|
||||||
if err := syscall.Kill(container.PID, syscall.SIGTERM); err != nil {
|
process, err := os.FindProcess(container.PID)
|
||||||
|
if err != nil {
|
||||||
|
// Process doesn't exist, update state
|
||||||
|
container.Status = StatusStopped
|
||||||
|
_ = m.state.Update(container)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send SIGTERM
|
||||||
|
if err := process.Signal(syscall.SIGTERM); err != nil {
|
||||||
// Process might already be gone
|
// Process might already be gone
|
||||||
container.Status = StatusStopped
|
container.Status = StatusStopped
|
||||||
_ = m.state.Update(container)
|
_ = m.state.Update(container)
|
||||||
|
|
@ -266,23 +267,28 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
|
||||||
|
|
||||||
// Honour already-cancelled contexts before waiting
|
// Honour already-cancelled contexts before waiting
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
_ = syscall.Kill(container.PID, syscall.SIGKILL)
|
_ = process.Signal(syscall.SIGKILL)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline := time.After(10 * time.Second)
|
// Wait for graceful shutdown with timeout
|
||||||
ticker := time.NewTicker(100 * time.Millisecond)
|
done := make(chan struct{})
|
||||||
defer ticker.Stop()
|
go func() {
|
||||||
|
_, _ = process.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
for isProcessRunning(container.PID) {
|
select {
|
||||||
select {
|
case <-done:
|
||||||
case <-deadline:
|
// Process exited gracefully
|
||||||
_ = syscall.Kill(container.PID, syscall.SIGKILL)
|
case <-time.After(10 * time.Second):
|
||||||
case <-ctx.Done():
|
// Force kill
|
||||||
_ = syscall.Kill(container.PID, syscall.SIGKILL)
|
_ = process.Signal(syscall.SIGKILL)
|
||||||
return ctx.Err()
|
<-done
|
||||||
case <-ticker.C:
|
case <-ctx.Done():
|
||||||
}
|
// Context cancelled
|
||||||
|
_ = process.Signal(syscall.SIGKILL)
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
container.Status = StatusStopped
|
container.Status = StatusStopped
|
||||||
|
|
@ -311,10 +317,14 @@ func (m *LinuxKitManager) List(ctx context.Context) ([]*Container, error) {
|
||||||
|
|
||||||
// isProcessRunning checks if a process with the given PID is still running.
|
// isProcessRunning checks if a process with the given PID is still running.
|
||||||
func isProcessRunning(pid int) bool {
|
func isProcessRunning(pid int) bool {
|
||||||
if pid <= 0 {
|
process, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return syscall.Kill(pid, syscall.Signal(0)) == nil
|
|
||||||
|
// On Unix, FindProcess always succeeds, so we need to send signal 0 to check
|
||||||
|
err = process.Signal(syscall.Signal(0))
|
||||||
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logs returns a reader for the container's log output.
|
// Logs returns a reader for the container's log output.
|
||||||
|
|
@ -426,7 +436,7 @@ func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) err
|
||||||
|
|
||||||
// Build SSH command
|
// Build SSH command
|
||||||
sshArgs := []string{
|
sshArgs := []string{
|
||||||
"-p", core.Sprintf("%d", sshPort),
|
"-p", fmt.Sprintf("%d", sshPort),
|
||||||
"-o", "StrictHostKeyChecking=yes",
|
"-o", "StrictHostKeyChecking=yes",
|
||||||
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
||||||
"-o", "LogLevel=ERROR",
|
"-o", "LogLevel=ERROR",
|
||||||
|
|
@ -434,10 +444,10 @@ func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) err
|
||||||
}
|
}
|
||||||
sshArgs = append(sshArgs, cmd...)
|
sshArgs = append(sshArgs, cmd...)
|
||||||
|
|
||||||
sshCmd := proc.NewCommandContext(ctx, "ssh", sshArgs...)
|
sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...)
|
||||||
sshCmd.Stdin = proc.Stdin
|
sshCmd.Stdin = os.Stdin
|
||||||
sshCmd.Stdout = proc.Stdout
|
sshCmd.Stdout = os.Stdout
|
||||||
sshCmd.Stderr = proc.Stderr
|
sshCmd.Stderr = os.Stderr
|
||||||
|
|
||||||
return sshCmd.Run()
|
return sshCmd.Run()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
159
linuxkit_test.go
159
linuxkit_test.go
|
|
@ -2,14 +2,13 @@ package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"syscall"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
"dappco.re/go/core/io"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -40,30 +39,30 @@ func (m *MockHypervisor) Available() bool {
|
||||||
return m.available
|
return m.available
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) {
|
func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) {
|
||||||
m.lastImage = image
|
m.lastImage = image
|
||||||
m.lastOpts = opts
|
m.lastOpts = opts
|
||||||
if m.buildErr != nil {
|
if m.buildErr != nil {
|
||||||
return nil, m.buildErr
|
return nil, m.buildErr
|
||||||
}
|
}
|
||||||
// Return a simple command that exits quickly
|
// Return a simple command that exits quickly
|
||||||
return proc.NewCommandContext(ctx, m.commandToRun, "test"), nil
|
return exec.CommandContext(ctx, m.commandToRun, "test"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestManager creates a LinuxKitManager with mock hypervisor for testing.
|
// newTestManager creates a LinuxKitManager with mock hypervisor for testing.
|
||||||
// Uses manual temp directory management to avoid race conditions with t.TempDir cleanup.
|
// Uses manual temp directory management to avoid race conditions with t.TempDir cleanup.
|
||||||
func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) {
|
func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) {
|
||||||
tmpDir, err := coreutil.MkdirTemp("linuxkit-test-")
|
tmpDir, err := os.MkdirTemp("", "linuxkit-test-*")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Manual cleanup that handles race conditions with state file writes
|
// Manual cleanup that handles race conditions with state file writes
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
// Give any pending file operations time to complete
|
// Give any pending file operations time to complete
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
_ = io.Local.DeleteAll(tmpDir)
|
_ = os.RemoveAll(tmpDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
|
|
||||||
state, err := LoadState(statePath)
|
state, err := LoadState(statePath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -74,9 +73,9 @@ func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) {
|
||||||
return manager, mock, tmpDir
|
return manager, mock, tmpDir
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKit_NewLinuxKitManagerWithHypervisor_Good(t *testing.T) {
|
func TestNewLinuxKitManagerWithHypervisor_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state, _ := LoadState(statePath)
|
state, _ := LoadState(statePath)
|
||||||
mock := NewMockHypervisor()
|
mock := NewMockHypervisor()
|
||||||
|
|
||||||
|
|
@ -87,12 +86,12 @@ func TestLinuxKit_NewLinuxKitManagerWithHypervisor_Good(t *testing.T) {
|
||||||
assert.Equal(t, mock, manager.Hypervisor())
|
assert.Equal(t, mock, manager.Hypervisor())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Run_Detached_Good(t *testing.T) {
|
func TestLinuxKitManager_Run_Good_Detached(t *testing.T) {
|
||||||
manager, mock, tmpDir := newTestManager(t)
|
manager, mock, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
// Create a test image file
|
// Create a test image file
|
||||||
imagePath := coreutil.JoinPath(tmpDir, "test.iso")
|
imagePath := filepath.Join(tmpDir, "test.iso")
|
||||||
err := io.Local.Write(imagePath, "fake image")
|
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Use a command that runs briefly then exits
|
// Use a command that runs briefly then exits
|
||||||
|
|
@ -126,11 +125,11 @@ func TestLinuxKitManager_Run_Detached_Good(t *testing.T) {
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Run_DefaultValues_Good(t *testing.T) {
|
func TestLinuxKitManager_Run_Good_DefaultValues(t *testing.T) {
|
||||||
manager, mock, tmpDir := newTestManager(t)
|
manager, mock, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
imagePath := coreutil.JoinPath(tmpDir, "test.qcow2")
|
imagePath := filepath.Join(tmpDir, "test.qcow2")
|
||||||
err := io.Local.Write(imagePath, "fake image")
|
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -151,7 +150,7 @@ func TestLinuxKitManager_Run_DefaultValues_Good(t *testing.T) {
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Run_ImageNotFound_Bad(t *testing.T) {
|
func TestLinuxKitManager_Run_Bad_ImageNotFound(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -162,11 +161,11 @@ func TestLinuxKitManager_Run_ImageNotFound_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "image not found")
|
assert.Contains(t, err.Error(), "image not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Run_UnsupportedFormat_Bad(t *testing.T) {
|
func TestLinuxKitManager_Run_Bad_UnsupportedFormat(t *testing.T) {
|
||||||
manager, _, tmpDir := newTestManager(t)
|
manager, _, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
imagePath := coreutil.JoinPath(tmpDir, "test.txt")
|
imagePath := filepath.Join(tmpDir, "test.txt")
|
||||||
err := io.Local.Write(imagePath, "not an image")
|
err := os.WriteFile(imagePath, []byte("not an image"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -202,7 +201,7 @@ func TestLinuxKitManager_Stop_Good(t *testing.T) {
|
||||||
assert.Equal(t, StatusStopped, c.Status)
|
assert.Equal(t, StatusStopped, c.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Stop_NotFound_Bad(t *testing.T) {
|
func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -212,9 +211,9 @@ func TestLinuxKitManager_Stop_NotFound_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "container not found")
|
assert.Contains(t, err.Error(), "container not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Stop_NotRunning_Bad(t *testing.T) {
|
func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) {
|
||||||
_, _, tmpDir := newTestManager(t)
|
_, _, tmpDir := newTestManager(t)
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state, err := LoadState(statePath)
|
state, err := LoadState(statePath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor())
|
manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor())
|
||||||
|
|
@ -234,7 +233,7 @@ func TestLinuxKitManager_Stop_NotRunning_Bad(t *testing.T) {
|
||||||
|
|
||||||
func TestLinuxKitManager_List_Good(t *testing.T) {
|
func TestLinuxKitManager_List_Good(t *testing.T) {
|
||||||
_, _, tmpDir := newTestManager(t)
|
_, _, tmpDir := newTestManager(t)
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state, err := LoadState(statePath)
|
state, err := LoadState(statePath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor())
|
manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor())
|
||||||
|
|
@ -249,9 +248,9 @@ func TestLinuxKitManager_List_Good(t *testing.T) {
|
||||||
assert.Len(t, containers, 2)
|
assert.Len(t, containers, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_List_VerifiesRunningStatus_Good(t *testing.T) {
|
func TestLinuxKitManager_List_Good_VerifiesRunningStatus(t *testing.T) {
|
||||||
_, _, tmpDir := newTestManager(t)
|
_, _, tmpDir := newTestManager(t)
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state, err := LoadState(statePath)
|
state, err := LoadState(statePath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor())
|
manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor())
|
||||||
|
|
@ -276,8 +275,8 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) {
|
||||||
manager, _, tmpDir := newTestManager(t)
|
manager, _, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
// Create a log file manually
|
// Create a log file manually
|
||||||
logsDir := coreutil.JoinPath(tmpDir, "logs")
|
logsDir := filepath.Join(tmpDir, "logs")
|
||||||
require.NoError(t, io.Local.EnsureDir(logsDir))
|
require.NoError(t, os.MkdirAll(logsDir, 0755))
|
||||||
|
|
||||||
container := &Container{ID: "abc12345"}
|
container := &Container{ID: "abc12345"}
|
||||||
_ = manager.State().Add(container)
|
_ = manager.State().Add(container)
|
||||||
|
|
@ -287,8 +286,8 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) {
|
||||||
logContent := "test log content\nline 2\n"
|
logContent := "test log content\nline 2\n"
|
||||||
logPath, err := LogPath("abc12345")
|
logPath, err := LogPath("abc12345")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, io.Local.EnsureDir(core.PathDir(logPath)))
|
require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755))
|
||||||
require.NoError(t, io.Local.Write(logPath, logContent))
|
require.NoError(t, os.WriteFile(logPath, []byte(logContent), 0644))
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
reader, err := manager.Logs(ctx, "abc12345", false)
|
reader, err := manager.Logs(ctx, "abc12345", false)
|
||||||
|
|
@ -301,7 +300,7 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) {
|
||||||
assert.Equal(t, logContent, string(buf[:n]))
|
assert.Equal(t, logContent, string(buf[:n]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Logs_NotFound_Bad(t *testing.T) {
|
func TestLinuxKitManager_Logs_Bad_NotFound(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -311,7 +310,7 @@ func TestLinuxKitManager_Logs_NotFound_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "container not found")
|
assert.Contains(t, err.Error(), "container not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Logs_NoLogFile_Bad(t *testing.T) {
|
func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
// Use a unique ID that won't have a log file
|
// Use a unique ID that won't have a log file
|
||||||
|
|
@ -334,7 +333,7 @@ func TestLinuxKitManager_Logs_NoLogFile_Bad(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Exec_NotFound_Bad(t *testing.T) {
|
func TestLinuxKitManager_Exec_Bad_NotFound(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -344,7 +343,7 @@ func TestLinuxKitManager_Exec_NotFound_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "container not found")
|
assert.Contains(t, err.Error(), "container not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Exec_NotRunning_Bad(t *testing.T) {
|
func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
container := &Container{ID: "abc12345", Status: StatusStopped}
|
container := &Container{ID: "abc12345", Status: StatusStopped}
|
||||||
|
|
@ -357,7 +356,7 @@ func TestLinuxKitManager_Exec_NotRunning_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "not running")
|
assert.Contains(t, err.Error(), "not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKit_DetectImageFormat_Good(t *testing.T) {
|
func TestDetectImageFormat_Good(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
path string
|
path string
|
||||||
format ImageFormat
|
format ImageFormat
|
||||||
|
|
@ -379,7 +378,7 @@ func TestLinuxKit_DetectImageFormat_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectImageFormat_Unknown_Bad(t *testing.T) {
|
func TestDetectImageFormat_Bad_Unknown(t *testing.T) {
|
||||||
tests := []string{
|
tests := []string{
|
||||||
"/path/to/image.txt",
|
"/path/to/image.txt",
|
||||||
"/path/to/image",
|
"/path/to/image",
|
||||||
|
|
@ -427,7 +426,7 @@ func TestQemuHypervisor_BuildCommand_Good(t *testing.T) {
|
||||||
assert.Contains(t, args, "-nographic")
|
assert.Contains(t, args, "-nographic")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Logs_Follow_Good(t *testing.T) {
|
func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
// Create a unique container ID
|
// Create a unique container ID
|
||||||
|
|
@ -439,10 +438,10 @@ func TestLinuxKitManager_Logs_Follow_Good(t *testing.T) {
|
||||||
// Create a log file at the expected location
|
// Create a log file at the expected location
|
||||||
logPath, err := LogPath(uniqueID)
|
logPath, err := LogPath(uniqueID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, io.Local.EnsureDir(core.PathDir(logPath)))
|
require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755))
|
||||||
|
|
||||||
// Write initial content
|
// Write initial content
|
||||||
err = io.Local.Write(logPath, "initial log content\n")
|
err = os.WriteFile(logPath, []byte("initial log content\n"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create a cancellable context
|
// Create a cancellable context
|
||||||
|
|
@ -465,13 +464,13 @@ func TestLinuxKitManager_Logs_Follow_Good(t *testing.T) {
|
||||||
assert.NoError(t, reader.Close())
|
assert.NoError(t, reader.Close())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFollowReader_Read_WithData_Good(t *testing.T) {
|
func TestFollowReader_Read_Good_WithData(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
logPath := coreutil.JoinPath(tmpDir, "test.log")
|
logPath := filepath.Join(tmpDir, "test.log")
|
||||||
|
|
||||||
// Create log file with content
|
// Create log file with content
|
||||||
content := "test log line 1\ntest log line 2\n"
|
content := "test log line 1\ntest log line 2\n"
|
||||||
err := io.Local.Write(logPath, content)
|
err := os.WriteFile(logPath, []byte(content), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
|
@ -482,9 +481,9 @@ func TestFollowReader_Read_WithData_Good(t *testing.T) {
|
||||||
defer func() { _ = reader.Close() }()
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
// The followReader seeks to end, so we need to append more content
|
// The followReader seeks to end, so we need to append more content
|
||||||
f, err := io.Local.Append(logPath)
|
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = f.Write([]byte("new line\n"))
|
_, err = f.WriteString("new line\n")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, f.Close())
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
|
|
@ -498,12 +497,12 @@ func TestFollowReader_Read_WithData_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFollowReader_Read_ContextCancel_Good(t *testing.T) {
|
func TestFollowReader_Read_Good_ContextCancel(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
logPath := coreutil.JoinPath(tmpDir, "test.log")
|
logPath := filepath.Join(tmpDir, "test.log")
|
||||||
|
|
||||||
// Create log file
|
// Create log file
|
||||||
err := io.Local.Write(logPath, "initial content\n")
|
err := os.WriteFile(logPath, []byte("initial content\n"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
@ -524,9 +523,9 @@ func TestFollowReader_Read_ContextCancel_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestFollowReader_Close_Good(t *testing.T) {
|
func TestFollowReader_Close_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
logPath := coreutil.JoinPath(tmpDir, "test.log")
|
logPath := filepath.Join(tmpDir, "test.log")
|
||||||
|
|
||||||
err := io.Local.Write(logPath, "content\n")
|
err := os.WriteFile(logPath, []byte("content\n"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -542,19 +541,19 @@ func TestFollowReader_Close_Good(t *testing.T) {
|
||||||
assert.Error(t, readErr)
|
assert.Error(t, readErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewFollowReader_FileNotFound_Bad(t *testing.T) {
|
func TestNewFollowReader_Bad_FileNotFound(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, err := newFollowReader(ctx, io.Local, "/nonexistent/path/to/file.log")
|
_, err := newFollowReader(ctx, io.Local, "/nonexistent/path/to/file.log")
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Run_BuildCommandError_Bad(t *testing.T) {
|
func TestLinuxKitManager_Run_Bad_BuildCommandError(t *testing.T) {
|
||||||
manager, mock, tmpDir := newTestManager(t)
|
manager, mock, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
// Create a test image file
|
// Create a test image file
|
||||||
imagePath := coreutil.JoinPath(tmpDir, "test.iso")
|
imagePath := filepath.Join(tmpDir, "test.iso")
|
||||||
err := io.Local.Write(imagePath, "fake image")
|
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Configure mock to return an error
|
// Configure mock to return an error
|
||||||
|
|
@ -568,12 +567,12 @@ func TestLinuxKitManager_Run_BuildCommandError_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "failed to build hypervisor command")
|
assert.Contains(t, err.Error(), "failed to build hypervisor command")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Run_Foreground_Good(t *testing.T) {
|
func TestLinuxKitManager_Run_Good_Foreground(t *testing.T) {
|
||||||
manager, mock, tmpDir := newTestManager(t)
|
manager, mock, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
// Create a test image file
|
// Create a test image file
|
||||||
imagePath := coreutil.JoinPath(tmpDir, "test.iso")
|
imagePath := filepath.Join(tmpDir, "test.iso")
|
||||||
err := io.Local.Write(imagePath, "fake image")
|
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Use echo which exits quickly
|
// Use echo which exits quickly
|
||||||
|
|
@ -596,12 +595,12 @@ func TestLinuxKitManager_Run_Foreground_Good(t *testing.T) {
|
||||||
assert.Equal(t, StatusStopped, container.Status)
|
assert.Equal(t, StatusStopped, container.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Stop_ContextCancelled_Good(t *testing.T) {
|
func TestLinuxKitManager_Stop_Good_ContextCancelled(t *testing.T) {
|
||||||
manager, mock, tmpDir := newTestManager(t)
|
manager, mock, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
// Create a test image file
|
// Create a test image file
|
||||||
imagePath := coreutil.JoinPath(tmpDir, "test.iso")
|
imagePath := filepath.Join(tmpDir, "test.iso")
|
||||||
err := io.Local.Write(imagePath, "fake image")
|
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Use a command that takes a long time
|
// Use a command that takes a long time
|
||||||
|
|
@ -633,23 +632,23 @@ func TestLinuxKitManager_Stop_ContextCancelled_Good(t *testing.T) {
|
||||||
assert.Equal(t, context.Canceled, err)
|
assert.Equal(t, context.Canceled, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsProcessRunning_ExistingProcess_Good(t *testing.T) {
|
func TestIsProcessRunning_Good_ExistingProcess(t *testing.T) {
|
||||||
// Use our own PID which definitely exists
|
// Use our own PID which definitely exists
|
||||||
running := isProcessRunning(syscall.Getpid())
|
running := isProcessRunning(os.Getpid())
|
||||||
assert.True(t, running)
|
assert.True(t, running)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsProcessRunning_NonexistentProcess_Bad(t *testing.T) {
|
func TestIsProcessRunning_Bad_NonexistentProcess(t *testing.T) {
|
||||||
// Use a PID that almost certainly doesn't exist
|
// Use a PID that almost certainly doesn't exist
|
||||||
running := isProcessRunning(999999)
|
running := isProcessRunning(999999)
|
||||||
assert.False(t, running)
|
assert.False(t, running)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Run_WithPortsAndVolumes_Good(t *testing.T) {
|
func TestLinuxKitManager_Run_Good_WithPortsAndVolumes(t *testing.T) {
|
||||||
manager, mock, tmpDir := newTestManager(t)
|
manager, mock, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
imagePath := coreutil.JoinPath(tmpDir, "test.iso")
|
imagePath := filepath.Join(tmpDir, "test.iso")
|
||||||
err := io.Local.Write(imagePath, "fake image")
|
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -674,12 +673,12 @@ func TestLinuxKitManager_Run_WithPortsAndVolumes_Good(t *testing.T) {
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFollowReader_Read_ReaderError_Bad(t *testing.T) {
|
func TestFollowReader_Read_Bad_ReaderError(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
logPath := coreutil.JoinPath(tmpDir, "test.log")
|
logPath := filepath.Join(tmpDir, "test.log")
|
||||||
|
|
||||||
// Create log file
|
// Create log file
|
||||||
err := io.Local.Write(logPath, "content\n")
|
err := os.WriteFile(logPath, []byte("content\n"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -695,11 +694,11 @@ func TestFollowReader_Read_ReaderError_Bad(t *testing.T) {
|
||||||
assert.Error(t, readErr)
|
assert.Error(t, readErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Run_StartError_Bad(t *testing.T) {
|
func TestLinuxKitManager_Run_Bad_StartError(t *testing.T) {
|
||||||
manager, mock, tmpDir := newTestManager(t)
|
manager, mock, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
imagePath := coreutil.JoinPath(tmpDir, "test.iso")
|
imagePath := filepath.Join(tmpDir, "test.iso")
|
||||||
err := io.Local.Write(imagePath, "fake image")
|
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Use a command that doesn't exist to cause Start() to fail
|
// Use a command that doesn't exist to cause Start() to fail
|
||||||
|
|
@ -716,11 +715,11 @@ func TestLinuxKitManager_Run_StartError_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "failed to start VM")
|
assert.Contains(t, err.Error(), "failed to start VM")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Run_ForegroundStartError_Bad(t *testing.T) {
|
func TestLinuxKitManager_Run_Bad_ForegroundStartError(t *testing.T) {
|
||||||
manager, mock, tmpDir := newTestManager(t)
|
manager, mock, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
imagePath := coreutil.JoinPath(tmpDir, "test.iso")
|
imagePath := filepath.Join(tmpDir, "test.iso")
|
||||||
err := io.Local.Write(imagePath, "fake image")
|
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Use a command that doesn't exist to cause Start() to fail
|
// Use a command that doesn't exist to cause Start() to fail
|
||||||
|
|
@ -737,11 +736,11 @@ func TestLinuxKitManager_Run_ForegroundStartError_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "failed to start VM")
|
assert.Contains(t, err.Error(), "failed to start VM")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Run_ForegroundWithError_Good(t *testing.T) {
|
func TestLinuxKitManager_Run_Good_ForegroundWithError(t *testing.T) {
|
||||||
manager, mock, tmpDir := newTestManager(t)
|
manager, mock, tmpDir := newTestManager(t)
|
||||||
|
|
||||||
imagePath := coreutil.JoinPath(tmpDir, "test.iso")
|
imagePath := filepath.Join(tmpDir, "test.iso")
|
||||||
err := io.Local.Write(imagePath, "fake image")
|
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Use a command that exits with error
|
// Use a command that exits with error
|
||||||
|
|
@ -760,7 +759,7 @@ func TestLinuxKitManager_Run_ForegroundWithError_Good(t *testing.T) {
|
||||||
assert.Equal(t, StatusError, container.Status)
|
assert.Equal(t, StatusError, container.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLinuxKitManager_Stop_ProcessExitedWhileRunning_Good(t *testing.T) {
|
func TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning(t *testing.T) {
|
||||||
manager, _, _ := newTestManager(t)
|
manager, _, _ := newTestManager(t)
|
||||||
|
|
||||||
// Add a "running" container with a process that has already exited
|
// Add a "running" container with a process that has already exited
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@ package sources
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
goio "io"
|
goio "io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CDNSource downloads images from a CDN or S3 bucket.
|
// CDNSource downloads images from a CDN or S3 bucket.
|
||||||
|
|
@ -21,10 +21,6 @@ type CDNSource struct {
|
||||||
var _ ImageSource = (*CDNSource)(nil)
|
var _ ImageSource = (*CDNSource)(nil)
|
||||||
|
|
||||||
// NewCDNSource creates a new CDN source.
|
// NewCDNSource creates a new CDN source.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// src := NewCDNSource(cfg)
|
|
||||||
func NewCDNSource(cfg SourceConfig) *CDNSource {
|
func NewCDNSource(cfg SourceConfig) *CDNSource {
|
||||||
return &CDNSource{config: cfg}
|
return &CDNSource{config: cfg}
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +38,7 @@ func (s *CDNSource) Available() bool {
|
||||||
// LatestVersion fetches version from manifest or returns "latest".
|
// LatestVersion fetches version from manifest or returns "latest".
|
||||||
func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) {
|
func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) {
|
||||||
// Try to fetch manifest.json for version info
|
// Try to fetch manifest.json for version info
|
||||||
url := core.Sprintf("%s/manifest.json", s.config.CDNURL)
|
url := fmt.Sprintf("%s/manifest.json", s.config.CDNURL)
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "latest", nil
|
return "latest", nil
|
||||||
|
|
@ -60,7 +56,7 @@ func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) {
|
||||||
|
|
||||||
// Download downloads the image from CDN.
|
// Download downloads the image from CDN.
|
||||||
func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error {
|
func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error {
|
||||||
url := core.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName)
|
url := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -74,7 +70,7 @@ func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, prog
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return coreerr.E("cdn.Download", core.Sprintf("HTTP %d", resp.StatusCode), nil)
|
return coreerr.E("cdn.Download", fmt.Sprintf("HTTP %d", resp.StatusCode), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure dest directory exists
|
// Ensure dest directory exists
|
||||||
|
|
@ -83,8 +79,8 @@ func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, prog
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination file
|
// Create destination file
|
||||||
destPath := coreutil.JoinPath(dest, s.config.ImageName)
|
destPath := filepath.Join(dest, s.config.ImageName)
|
||||||
f, err := m.Create(destPath)
|
f, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("cdn.Download", "create destination file", err)
|
return coreerr.E("cdn.Download", "create destination file", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,18 @@ package sources
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
goio "io"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
"dappco.re/go/core/io"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCDNSource_Available_Good(t *testing.T) {
|
func TestCDNSource_Good_Available(t *testing.T) {
|
||||||
src := NewCDNSource(SourceConfig{
|
src := NewCDNSource(SourceConfig{
|
||||||
CDNURL: "https://images.example.com",
|
CDNURL: "https://images.example.com",
|
||||||
ImageName: "core-devops-darwin-arm64.qcow2",
|
ImageName: "core-devops-darwin-arm64.qcow2",
|
||||||
|
|
@ -23,7 +23,7 @@ func TestCDNSource_Available_Good(t *testing.T) {
|
||||||
assert.True(t, src.Available())
|
assert.True(t, src.Available())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNSource_NoURL_Bad(t *testing.T) {
|
func TestCDNSource_Bad_NoURL(t *testing.T) {
|
||||||
src := NewCDNSource(SourceConfig{
|
src := NewCDNSource(SourceConfig{
|
||||||
ImageName: "core-devops-darwin-arm64.qcow2",
|
ImageName: "core-devops-darwin-arm64.qcow2",
|
||||||
})
|
})
|
||||||
|
|
@ -35,7 +35,7 @@ func TestCDNSource_LatestVersion_Good(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/manifest.json" {
|
if r.URL.Path == "/manifest.json" {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = goio.WriteString(w, `{"version": "1.2.3"}`)
|
_, _ = fmt.Fprint(w, `{"version": "1.2.3"}`)
|
||||||
} else {
|
} else {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +57,7 @@ func TestCDNSource_Download_Good(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/test.img" {
|
if r.URL.Path == "/test.img" {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = goio.WriteString(w, content)
|
_, _ = fmt.Fprint(w, content)
|
||||||
} else {
|
} else {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
@ -80,9 +80,9 @@ func TestCDNSource_Download_Good(t *testing.T) {
|
||||||
assert.True(t, progressCalled)
|
assert.True(t, progressCalled)
|
||||||
|
|
||||||
// Verify file content
|
// Verify file content
|
||||||
data, err := io.Local.Read(coreutil.JoinPath(dest, imageName))
|
data, err := os.ReadFile(filepath.Join(dest, imageName))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, content, data)
|
assert.Equal(t, content, string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNSource_Download_Bad(t *testing.T) {
|
func TestCDNSource_Download_Bad(t *testing.T) {
|
||||||
|
|
@ -115,7 +115,7 @@ func TestCDNSource_Download_Bad(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNSource_LatestVersion_NoManifest_Bad(t *testing.T) {
|
func TestCDNSource_LatestVersion_Bad_NoManifest(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}))
|
}))
|
||||||
|
|
@ -131,7 +131,7 @@ func TestCDNSource_LatestVersion_NoManifest_Bad(t *testing.T) {
|
||||||
assert.Equal(t, "latest", version)
|
assert.Equal(t, "latest", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNSource_LatestVersion_ServerError_Bad(t *testing.T) {
|
func TestCDNSource_LatestVersion_Bad_ServerError(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}))
|
}))
|
||||||
|
|
@ -147,12 +147,12 @@ func TestCDNSource_LatestVersion_ServerError_Bad(t *testing.T) {
|
||||||
assert.Equal(t, "latest", version)
|
assert.Equal(t, "latest", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNSource_Download_NoProgress_Good(t *testing.T) {
|
func TestCDNSource_Download_Good_NoProgress(t *testing.T) {
|
||||||
content := "test content"
|
content := "test content"
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Length", core.Sprintf("%d", len(content)))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = goio.WriteString(w, content)
|
_, _ = fmt.Fprint(w, content)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
|
|
@ -166,12 +166,12 @@ func TestCDNSource_Download_NoProgress_Good(t *testing.T) {
|
||||||
err := src.Download(context.Background(), io.Local, dest, nil)
|
err := src.Download(context.Background(), io.Local, dest, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
data, err := io.Local.Read(coreutil.JoinPath(dest, "test.img"))
|
data, err := os.ReadFile(filepath.Join(dest, "test.img"))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, content, data)
|
assert.Equal(t, content, string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNSource_Download_LargeFile_Good(t *testing.T) {
|
func TestCDNSource_Download_Good_LargeFile(t *testing.T) {
|
||||||
// Create content larger than buffer size (32KB)
|
// Create content larger than buffer size (32KB)
|
||||||
content := make([]byte, 64*1024) // 64KB
|
content := make([]byte, 64*1024) // 64KB
|
||||||
for i := range content {
|
for i := range content {
|
||||||
|
|
@ -179,7 +179,7 @@ func TestCDNSource_Download_LargeFile_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Length", core.Sprintf("%d", len(content)))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(content)
|
_, _ = w.Write(content)
|
||||||
}))
|
}))
|
||||||
|
|
@ -203,7 +203,7 @@ func TestCDNSource_Download_LargeFile_Good(t *testing.T) {
|
||||||
assert.Equal(t, int64(len(content)), lastDownloaded)
|
assert.Equal(t, int64(len(content)), lastDownloaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNSource_Download_HTTPErrorCodes_Bad(t *testing.T) {
|
func TestCDNSource_Download_Bad_HTTPErrorCodes(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
statusCode int
|
statusCode int
|
||||||
|
|
@ -230,17 +230,17 @@ func TestCDNSource_Download_HTTPErrorCodes_Bad(t *testing.T) {
|
||||||
|
|
||||||
err := src.Download(context.Background(), io.Local, dest, nil)
|
err := src.Download(context.Background(), io.Local, dest, nil)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), core.Sprintf("HTTP %d", tc.statusCode))
|
assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", tc.statusCode))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNSource_InterfaceCompliance_Good(t *testing.T) {
|
func TestCDNSource_InterfaceCompliance(t *testing.T) {
|
||||||
// Verify CDNSource implements ImageSource
|
// Verify CDNSource implements ImageSource
|
||||||
var _ ImageSource = (*CDNSource)(nil)
|
var _ ImageSource = (*CDNSource)(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNSource_Config_Good(t *testing.T) {
|
func TestCDNSource_Config(t *testing.T) {
|
||||||
cfg := SourceConfig{
|
cfg := SourceConfig{
|
||||||
CDNURL: "https://cdn.example.com",
|
CDNURL: "https://cdn.example.com",
|
||||||
ImageName: "my-image.qcow2",
|
ImageName: "my-image.qcow2",
|
||||||
|
|
@ -251,7 +251,7 @@ func TestCDNSource_Config_Good(t *testing.T) {
|
||||||
assert.Equal(t, "my-image.qcow2", src.config.ImageName)
|
assert.Equal(t, "my-image.qcow2", src.config.ImageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDN_NewCDNSource_Good(t *testing.T) {
|
func TestNewCDNSource_Good(t *testing.T) {
|
||||||
cfg := SourceConfig{
|
cfg := SourceConfig{
|
||||||
GitHubRepo: "host-uk/core-images",
|
GitHubRepo: "host-uk/core-images",
|
||||||
RegistryImage: "ghcr.io/host-uk/core-devops",
|
RegistryImage: "ghcr.io/host-uk/core-devops",
|
||||||
|
|
@ -265,16 +265,16 @@ func TestCDN_NewCDNSource_Good(t *testing.T) {
|
||||||
assert.Equal(t, cfg.CDNURL, src.config.CDNURL)
|
assert.Equal(t, cfg.CDNURL, src.config.CDNURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNSource_Download_CreatesDestDir_Good(t *testing.T) {
|
func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) {
|
||||||
content := "test content"
|
content := "test content"
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = goio.WriteString(w, content)
|
_, _ = fmt.Fprint(w, content)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
dest := coreutil.JoinPath(tmpDir, "nested", "dir")
|
dest := filepath.Join(tmpDir, "nested", "dir")
|
||||||
// dest doesn't exist yet
|
// dest doesn't exist yet
|
||||||
|
|
||||||
src := NewCDNSource(SourceConfig{
|
src := NewCDNSource(SourceConfig{
|
||||||
|
|
@ -286,12 +286,12 @@ func TestCDNSource_Download_CreatesDestDir_Good(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify nested dir was created
|
// Verify nested dir was created
|
||||||
info, err := io.Local.Stat(dest)
|
info, err := os.Stat(dest)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, info.IsDir())
|
assert.True(t, info.IsDir())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSourceConfig_Struct_Good(t *testing.T) {
|
func TestSourceConfig_Struct(t *testing.T) {
|
||||||
cfg := SourceConfig{
|
cfg := SourceConfig{
|
||||||
GitHubRepo: "owner/repo",
|
GitHubRepo: "owner/repo",
|
||||||
RegistryImage: "ghcr.io/owner/image",
|
RegistryImage: "ghcr.io/owner/image",
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ package sources
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/proc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GitHubSource downloads images from GitHub Releases.
|
// GitHubSource downloads images from GitHub Releases.
|
||||||
|
|
@ -19,10 +19,6 @@ type GitHubSource struct {
|
||||||
var _ ImageSource = (*GitHubSource)(nil)
|
var _ ImageSource = (*GitHubSource)(nil)
|
||||||
|
|
||||||
// NewGitHubSource creates a new GitHub source.
|
// NewGitHubSource creates a new GitHub source.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// src := NewGitHubSource(cfg)
|
|
||||||
func NewGitHubSource(cfg SourceConfig) *GitHubSource {
|
func NewGitHubSource(cfg SourceConfig) *GitHubSource {
|
||||||
return &GitHubSource{config: cfg}
|
return &GitHubSource{config: cfg}
|
||||||
}
|
}
|
||||||
|
|
@ -34,18 +30,18 @@ func (s *GitHubSource) Name() string {
|
||||||
|
|
||||||
// Available checks if gh CLI is installed and authenticated.
|
// Available checks if gh CLI is installed and authenticated.
|
||||||
func (s *GitHubSource) Available() bool {
|
func (s *GitHubSource) Available() bool {
|
||||||
_, err := proc.LookPath("gh")
|
_, err := exec.LookPath("gh")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Check if authenticated
|
// Check if authenticated
|
||||||
cmd := proc.NewCommand("gh", "auth", "status")
|
cmd := exec.Command("gh", "auth", "status")
|
||||||
return cmd.Run() == nil
|
return cmd.Run() == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestVersion returns the latest release tag.
|
// LatestVersion returns the latest release tag.
|
||||||
func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) {
|
func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) {
|
||||||
cmd := proc.NewCommandContext(ctx, "gh", "release", "view",
|
cmd := exec.CommandContext(ctx, "gh", "release", "view",
|
||||||
"-R", s.config.GitHubRepo,
|
"-R", s.config.GitHubRepo,
|
||||||
"--json", "tagName",
|
"--json", "tagName",
|
||||||
"-q", ".tagName",
|
"-q", ".tagName",
|
||||||
|
|
@ -54,20 +50,20 @@ func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", coreerr.E("github.LatestVersion", "failed", err)
|
return "", coreerr.E("github.LatestVersion", "failed", err)
|
||||||
}
|
}
|
||||||
return core.Trim(string(out)), nil
|
return strings.TrimSpace(string(out)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download downloads the image from the latest release.
|
// Download downloads the image from the latest release.
|
||||||
func (s *GitHubSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error {
|
func (s *GitHubSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error {
|
||||||
// Get release assets to find our image
|
// Get release assets to find our image
|
||||||
cmd := proc.NewCommandContext(ctx, "gh", "release", "download",
|
cmd := exec.CommandContext(ctx, "gh", "release", "download",
|
||||||
"-R", s.config.GitHubRepo,
|
"-R", s.config.GitHubRepo,
|
||||||
"-p", s.config.ImageName,
|
"-p", s.config.ImageName,
|
||||||
"-D", dest,
|
"-D", dest,
|
||||||
"--clobber",
|
"--clobber",
|
||||||
)
|
)
|
||||||
cmd.Stdout = proc.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = proc.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return coreerr.E("github.Download", "failed", err)
|
return coreerr.E("github.Download", "failed", err)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGitHubSource_Available_Good(t *testing.T) {
|
func TestGitHubSource_Good_Available(t *testing.T) {
|
||||||
src := NewGitHubSource(SourceConfig{
|
src := NewGitHubSource(SourceConfig{
|
||||||
GitHubRepo: "host-uk/core-images",
|
GitHubRepo: "host-uk/core-images",
|
||||||
ImageName: "core-devops-darwin-arm64.qcow2",
|
ImageName: "core-devops-darwin-arm64.qcow2",
|
||||||
|
|
@ -20,12 +20,12 @@ func TestGitHubSource_Available_Good(t *testing.T) {
|
||||||
_ = src.Available()
|
_ = src.Available()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitHubSource_Name_Good(t *testing.T) {
|
func TestGitHubSource_Name(t *testing.T) {
|
||||||
src := NewGitHubSource(SourceConfig{})
|
src := NewGitHubSource(SourceConfig{})
|
||||||
assert.Equal(t, "github", src.Name())
|
assert.Equal(t, "github", src.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitHubSource_Config_Good(t *testing.T) {
|
func TestGitHubSource_Config(t *testing.T) {
|
||||||
cfg := SourceConfig{
|
cfg := SourceConfig{
|
||||||
GitHubRepo: "owner/repo",
|
GitHubRepo: "owner/repo",
|
||||||
ImageName: "test-image.qcow2",
|
ImageName: "test-image.qcow2",
|
||||||
|
|
@ -37,7 +37,7 @@ func TestGitHubSource_Config_Good(t *testing.T) {
|
||||||
assert.Equal(t, "test-image.qcow2", src.config.ImageName)
|
assert.Equal(t, "test-image.qcow2", src.config.ImageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitHubSource_Multiple_Good(t *testing.T) {
|
func TestGitHubSource_Good_Multiple(t *testing.T) {
|
||||||
// Test creating multiple sources with different configs
|
// Test creating multiple sources with different configs
|
||||||
src1 := NewGitHubSource(SourceConfig{GitHubRepo: "org1/repo1", ImageName: "img1.qcow2"})
|
src1 := NewGitHubSource(SourceConfig{GitHubRepo: "org1/repo1", ImageName: "img1.qcow2"})
|
||||||
src2 := NewGitHubSource(SourceConfig{GitHubRepo: "org2/repo2", ImageName: "img2.qcow2"})
|
src2 := NewGitHubSource(SourceConfig{GitHubRepo: "org2/repo2", ImageName: "img2.qcow2"})
|
||||||
|
|
@ -48,7 +48,7 @@ func TestGitHubSource_Multiple_Good(t *testing.T) {
|
||||||
assert.Equal(t, "github", src2.Name())
|
assert.Equal(t, "github", src2.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitHub_NewGitHubSource_Good(t *testing.T) {
|
func TestNewGitHubSource_Good(t *testing.T) {
|
||||||
cfg := SourceConfig{
|
cfg := SourceConfig{
|
||||||
GitHubRepo: "host-uk/core-images",
|
GitHubRepo: "host-uk/core-images",
|
||||||
RegistryImage: "ghcr.io/host-uk/core-devops",
|
RegistryImage: "ghcr.io/host-uk/core-devops",
|
||||||
|
|
@ -62,7 +62,7 @@ func TestGitHub_NewGitHubSource_Good(t *testing.T) {
|
||||||
assert.Equal(t, cfg.GitHubRepo, src.config.GitHubRepo)
|
assert.Equal(t, cfg.GitHubRepo, src.config.GitHubRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitHubSource_InterfaceCompliance_Good(t *testing.T) {
|
func TestGitHubSource_InterfaceCompliance(t *testing.T) {
|
||||||
// Verify GitHubSource implements ImageSource
|
// Verify GitHubSource implements ImageSource
|
||||||
var _ ImageSource = (*GitHubSource)(nil)
|
var _ ImageSource = (*GitHubSource)(nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// Package sources provides image download sources for container.
|
// Package sources provides image download sources for go-container.
|
||||||
package sources
|
package sources
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"dappco.re/go/core/io"
|
"forge.lthn.ai/core/go-io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageSource defines the interface for downloading dev images.
|
// ImageSource defines the interface for downloading dev images.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSourceConfig_Empty_Good(t *testing.T) {
|
func TestSourceConfig_Empty(t *testing.T) {
|
||||||
cfg := SourceConfig{}
|
cfg := SourceConfig{}
|
||||||
assert.Empty(t, cfg.GitHubRepo)
|
assert.Empty(t, cfg.GitHubRepo)
|
||||||
assert.Empty(t, cfg.RegistryImage)
|
assert.Empty(t, cfg.RegistryImage)
|
||||||
|
|
@ -14,7 +14,7 @@ func TestSourceConfig_Empty_Good(t *testing.T) {
|
||||||
assert.Empty(t, cfg.ImageName)
|
assert.Empty(t, cfg.ImageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSourceConfig_Complete_Good(t *testing.T) {
|
func TestSourceConfig_Complete(t *testing.T) {
|
||||||
cfg := SourceConfig{
|
cfg := SourceConfig{
|
||||||
GitHubRepo: "owner/repo",
|
GitHubRepo: "owner/repo",
|
||||||
RegistryImage: "ghcr.io/owner/image:v1",
|
RegistryImage: "ghcr.io/owner/image:v1",
|
||||||
|
|
@ -28,7 +28,7 @@ func TestSourceConfig_Complete_Good(t *testing.T) {
|
||||||
assert.Equal(t, "my-image-darwin-arm64.qcow2", cfg.ImageName)
|
assert.Equal(t, "my-image-darwin-arm64.qcow2", cfg.ImageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageSource_Interface_Good(t *testing.T) {
|
func TestImageSource_Interface(t *testing.T) {
|
||||||
// Ensure both sources implement the interface
|
// Ensure both sources implement the interface
|
||||||
var _ ImageSource = (*GitHubSource)(nil)
|
var _ ImageSource = (*GitHubSource)(nil)
|
||||||
var _ ImageSource = (*CDNSource)(nil)
|
var _ ImageSource = (*CDNSource)(nil)
|
||||||
|
|
|
||||||
68
state.go
68
state.go
|
|
@ -1,13 +1,12 @@
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// State manages persistent container state.
|
// State manages persistent container state.
|
||||||
|
|
@ -20,49 +19,33 @@ type State struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultStateDir returns the default directory for state files (~/.core).
|
// DefaultStateDir returns the default directory for state files (~/.core).
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// dir, err := DefaultStateDir()
|
|
||||||
func DefaultStateDir() (string, error) {
|
func DefaultStateDir() (string, error) {
|
||||||
home := coreutil.HomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if home == "" {
|
if err != nil {
|
||||||
return "", core.E("DefaultStateDir", "home directory not available", nil)
|
return "", err
|
||||||
}
|
}
|
||||||
return coreutil.JoinPath(home, ".core"), nil
|
return filepath.Join(home, ".core"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultStatePath returns the default path for the state file.
|
// DefaultStatePath returns the default path for the state file.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// path, err := DefaultStatePath()
|
|
||||||
func DefaultStatePath() (string, error) {
|
func DefaultStatePath() (string, error) {
|
||||||
dir, err := DefaultStateDir()
|
dir, err := DefaultStateDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return coreutil.JoinPath(dir, "containers.json"), nil
|
return filepath.Join(dir, "containers.json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultLogsDir returns the default directory for container logs.
|
// DefaultLogsDir returns the default directory for container logs.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// dir, err := DefaultLogsDir()
|
|
||||||
func DefaultLogsDir() (string, error) {
|
func DefaultLogsDir() (string, error) {
|
||||||
dir, err := DefaultStateDir()
|
dir, err := DefaultStateDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return coreutil.JoinPath(dir, "logs"), nil
|
return filepath.Join(dir, "logs"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewState creates a new State instance.
|
// NewState creates a new State instance.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// state := NewState("/tmp/containers.json")
|
|
||||||
func NewState(filePath string) *State {
|
func NewState(filePath string) *State {
|
||||||
return &State{
|
return &State{
|
||||||
Containers: make(map[string]*Container),
|
Containers: make(map[string]*Container),
|
||||||
|
|
@ -72,24 +55,19 @@ func NewState(filePath string) *State {
|
||||||
|
|
||||||
// LoadState loads the state from the given file path.
|
// LoadState loads the state from the given file path.
|
||||||
// If the file doesn't exist, returns an empty state.
|
// If the file doesn't exist, returns an empty state.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// state, err := LoadState("/tmp/containers.json")
|
|
||||||
func LoadState(filePath string) (*State, error) {
|
func LoadState(filePath string) (*State, error) {
|
||||||
state := NewState(filePath)
|
state := NewState(filePath)
|
||||||
|
|
||||||
dataStr, err := io.Local.Read(filePath)
|
dataStr, err := io.Local.Read(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if core.Is(err, fs.ErrNotExist) {
|
if os.IsNotExist(err) {
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := core.JSONUnmarshalString(dataStr, state)
|
if err := json.Unmarshal([]byte(dataStr), state); err != nil {
|
||||||
if !result.OK {
|
return nil, err
|
||||||
return nil, result.Value.(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return state, nil
|
return state, nil
|
||||||
|
|
@ -101,17 +79,17 @@ func (s *State) SaveState() error {
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
// Ensure the directory exists
|
// Ensure the directory exists
|
||||||
dir := core.PathDir(s.filePath)
|
dir := filepath.Dir(s.filePath)
|
||||||
if err := io.Local.EnsureDir(dir); err != nil {
|
if err := io.Local.EnsureDir(dir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := core.JSONMarshal(s)
|
data, err := json.MarshalIndent(s, "", " ")
|
||||||
if !result.OK {
|
if err != nil {
|
||||||
return result.Value.(error)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return io.Local.Write(s.filePath, string(result.Value.([]byte)))
|
return io.Local.Write(s.filePath, string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds a container to the state and persists it.
|
// Add adds a container to the state and persists it.
|
||||||
|
|
@ -176,23 +154,15 @@ func (s *State) FilePath() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogPath returns the log file path for a given container ID.
|
// LogPath returns the log file path for a given container ID.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// path, err := LogPath(containerID)
|
|
||||||
func LogPath(id string) (string, error) {
|
func LogPath(id string) (string, error) {
|
||||||
logsDir, err := DefaultLogsDir()
|
logsDir, err := DefaultLogsDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return coreutil.JoinPath(logsDir, core.Concat(id, ".log")), nil
|
return filepath.Join(logsDir, id+".log"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureLogsDir ensures the logs directory exists.
|
// EnsureLogsDir ensures the logs directory exists.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// err := EnsureLogsDir()
|
|
||||||
func EnsureLogsDir() error {
|
func EnsureLogsDir() error {
|
||||||
logsDir, err := DefaultLogsDir()
|
logsDir, err := DefaultLogsDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"dappco.re/go/core/io"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestState_NewState_Good(t *testing.T) {
|
func TestNewState_Good(t *testing.T) {
|
||||||
state := NewState("/tmp/test-state.json")
|
state := NewState("/tmp/test-state.json")
|
||||||
|
|
||||||
assert.NotNil(t, state)
|
assert.NotNil(t, state)
|
||||||
|
|
@ -20,10 +18,10 @@ func TestState_NewState_Good(t *testing.T) {
|
||||||
assert.Equal(t, "/tmp/test-state.json", state.FilePath())
|
assert.Equal(t, "/tmp/test-state.json", state.FilePath())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadState_NewFile_Good(t *testing.T) {
|
func TestLoadState_Good_NewFile(t *testing.T) {
|
||||||
// Test loading from non-existent file
|
// Test loading from non-existent file
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
|
|
||||||
state, err := LoadState(statePath)
|
state, err := LoadState(statePath)
|
||||||
|
|
||||||
|
|
@ -32,9 +30,9 @@ func TestLoadState_NewFile_Good(t *testing.T) {
|
||||||
assert.Empty(t, state.Containers)
|
assert.Empty(t, state.Containers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadState_ExistingFile_Good(t *testing.T) {
|
func TestLoadState_Good_ExistingFile(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
|
|
||||||
// Create a state file with data
|
// Create a state file with data
|
||||||
content := `{
|
content := `{
|
||||||
|
|
@ -49,7 +47,7 @@ func TestLoadState_ExistingFile_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
err := io.Local.Write(statePath, content)
|
err := os.WriteFile(statePath, []byte(content), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
state, err := LoadState(statePath)
|
state, err := LoadState(statePath)
|
||||||
|
|
@ -63,12 +61,12 @@ func TestLoadState_ExistingFile_Good(t *testing.T) {
|
||||||
assert.Equal(t, StatusRunning, c.Status)
|
assert.Equal(t, StatusRunning, c.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadState_InvalidJSON_Bad(t *testing.T) {
|
func TestLoadState_Bad_InvalidJSON(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
|
|
||||||
// Create invalid JSON
|
// Create invalid JSON
|
||||||
err := io.Local.Write(statePath, "invalid json{")
|
err := os.WriteFile(statePath, []byte("invalid json{"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = LoadState(statePath)
|
_, err = LoadState(statePath)
|
||||||
|
|
@ -77,7 +75,7 @@ func TestLoadState_InvalidJSON_Bad(t *testing.T) {
|
||||||
|
|
||||||
func TestState_Add_Good(t *testing.T) {
|
func TestState_Add_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state := NewState(statePath)
|
state := NewState(statePath)
|
||||||
|
|
||||||
container := &Container{
|
container := &Container{
|
||||||
|
|
@ -98,12 +96,13 @@ func TestState_Add_Good(t *testing.T) {
|
||||||
assert.Equal(t, container.Name, c.Name)
|
assert.Equal(t, container.Name, c.Name)
|
||||||
|
|
||||||
// Verify file was created
|
// Verify file was created
|
||||||
assert.True(t, io.Local.IsFile(statePath))
|
_, err = os.Stat(statePath)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestState_Update_Good(t *testing.T) {
|
func TestState_Update_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state := NewState(statePath)
|
state := NewState(statePath)
|
||||||
|
|
||||||
container := &Container{
|
container := &Container{
|
||||||
|
|
@ -125,7 +124,7 @@ func TestState_Update_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestState_Remove_Good(t *testing.T) {
|
func TestState_Remove_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state := NewState(statePath)
|
state := NewState(statePath)
|
||||||
|
|
||||||
container := &Container{
|
container := &Container{
|
||||||
|
|
@ -140,7 +139,7 @@ func TestState_Remove_Good(t *testing.T) {
|
||||||
assert.False(t, ok)
|
assert.False(t, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestState_Get_NotFound_Bad(t *testing.T) {
|
func TestState_Get_Bad_NotFound(t *testing.T) {
|
||||||
state := NewState("/tmp/test-state.json")
|
state := NewState("/tmp/test-state.json")
|
||||||
|
|
||||||
_, ok := state.Get("nonexistent")
|
_, ok := state.Get("nonexistent")
|
||||||
|
|
@ -149,7 +148,7 @@ func TestState_Get_NotFound_Bad(t *testing.T) {
|
||||||
|
|
||||||
func TestState_All_Good(t *testing.T) {
|
func TestState_All_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
statePath := coreutil.JoinPath(tmpDir, "containers.json")
|
statePath := filepath.Join(tmpDir, "containers.json")
|
||||||
state := NewState(statePath)
|
state := NewState(statePath)
|
||||||
|
|
||||||
_ = state.Add(&Container{ID: "aaa11111"})
|
_ = state.Add(&Container{ID: "aaa11111"})
|
||||||
|
|
@ -160,9 +159,9 @@ func TestState_All_Good(t *testing.T) {
|
||||||
assert.Len(t, all, 3)
|
assert.Len(t, all, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestState_SaveState_CreatesDirectory_Good(t *testing.T) {
|
func TestState_SaveState_Good_CreatesDirectory(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
nestedPath := coreutil.JoinPath(tmpDir, "nested", "dir", "containers.json")
|
nestedPath := filepath.Join(tmpDir, "nested", "dir", "containers.json")
|
||||||
state := NewState(nestedPath)
|
state := NewState(nestedPath)
|
||||||
|
|
||||||
_ = state.Add(&Container{ID: "abc12345"})
|
_ = state.Add(&Container{ID: "abc12345"})
|
||||||
|
|
@ -171,43 +170,45 @@ func TestState_SaveState_CreatesDirectory_Good(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify directory was created
|
// Verify directory was created
|
||||||
assert.True(t, io.Local.IsDir(core.PathDir(nestedPath)))
|
_, err = os.Stat(filepath.Dir(nestedPath))
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestState_DefaultStateDir_Good(t *testing.T) {
|
func TestDefaultStateDir_Good(t *testing.T) {
|
||||||
dir, err := DefaultStateDir()
|
dir, err := DefaultStateDir()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, dir, ".core")
|
assert.Contains(t, dir, ".core")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestState_DefaultStatePath_Good(t *testing.T) {
|
func TestDefaultStatePath_Good(t *testing.T) {
|
||||||
path, err := DefaultStatePath()
|
path, err := DefaultStatePath()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, path, "containers.json")
|
assert.Contains(t, path, "containers.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestState_DefaultLogsDir_Good(t *testing.T) {
|
func TestDefaultLogsDir_Good(t *testing.T) {
|
||||||
dir, err := DefaultLogsDir()
|
dir, err := DefaultLogsDir()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, dir, "logs")
|
assert.Contains(t, dir, "logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestState_LogPath_Good(t *testing.T) {
|
func TestLogPath_Good(t *testing.T) {
|
||||||
path, err := LogPath("abc12345")
|
path, err := LogPath("abc12345")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, path, "abc12345.log")
|
assert.Contains(t, path, "abc12345.log")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestState_EnsureLogsDir_Good(t *testing.T) {
|
func TestEnsureLogsDir_Good(t *testing.T) {
|
||||||
// This test creates real directories - skip in CI if needed
|
// This test creates real directories - skip in CI if needed
|
||||||
err := EnsureLogsDir()
|
err := EnsureLogsDir()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
logsDir, _ := DefaultLogsDir()
|
logsDir, _ := DefaultLogsDir()
|
||||||
assert.True(t, io.Local.IsDir(logsDir))
|
_, err = os.Stat(logsDir)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestState_GenerateID_Good(t *testing.T) {
|
func TestGenerateID_Good(t *testing.T) {
|
||||||
id1, err := GenerateID()
|
id1, err := GenerateID()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, id1, 8)
|
assert.Len(t, id1, 8)
|
||||||
|
|
|
||||||
69
templates.go
69
templates.go
|
|
@ -4,14 +4,14 @@ import (
|
||||||
"embed"
|
"embed"
|
||||||
"iter"
|
"iter"
|
||||||
"maps"
|
"maps"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
"forge.lthn.ai/core/go-io"
|
||||||
"dappco.re/go/core/io"
|
coreerr "forge.lthn.ai/core/go-log"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*.yml
|
//go:embed templates/*.yml
|
||||||
|
|
@ -44,19 +44,11 @@ var builtinTemplates = []Template{
|
||||||
// ListTemplates returns all available LinuxKit templates.
|
// ListTemplates returns all available LinuxKit templates.
|
||||||
// It combines embedded templates with any templates found in the user's
|
// It combines embedded templates with any templates found in the user's
|
||||||
// .core/linuxkit directory.
|
// .core/linuxkit directory.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// templates := ListTemplates()
|
|
||||||
func ListTemplates() []Template {
|
func ListTemplates() []Template {
|
||||||
return slices.Collect(ListTemplatesIter())
|
return slices.Collect(ListTemplatesIter())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTemplatesIter returns an iterator for all available LinuxKit templates.
|
// ListTemplatesIter returns an iterator for all available LinuxKit templates.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// for template := range ListTemplatesIter() { _ = template }
|
|
||||||
func ListTemplatesIter() iter.Seq[Template] {
|
func ListTemplatesIter() iter.Seq[Template] {
|
||||||
return func(yield func(Template) bool) {
|
return func(yield func(Template) bool) {
|
||||||
// Yield builtin templates
|
// Yield builtin templates
|
||||||
|
|
@ -80,10 +72,6 @@ func ListTemplatesIter() iter.Seq[Template] {
|
||||||
|
|
||||||
// GetTemplate returns the content of a template by name.
|
// GetTemplate returns the content of a template by name.
|
||||||
// It first checks embedded templates, then user templates.
|
// It first checks embedded templates, then user templates.
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// content, err := GetTemplate("core-dev")
|
|
||||||
func GetTemplate(name string) (string, error) {
|
func GetTemplate(name string) (string, error) {
|
||||||
// Check embedded templates first
|
// Check embedded templates first
|
||||||
for _, t := range builtinTemplates {
|
for _, t := range builtinTemplates {
|
||||||
|
|
@ -99,7 +87,7 @@ func GetTemplate(name string) (string, error) {
|
||||||
// Check user templates
|
// Check user templates
|
||||||
userTemplatesDir := getUserTemplatesDir()
|
userTemplatesDir := getUserTemplatesDir()
|
||||||
if userTemplatesDir != "" {
|
if userTemplatesDir != "" {
|
||||||
templatePath := coreutil.JoinPath(userTemplatesDir, core.Concat(name, ".yml"))
|
templatePath := filepath.Join(userTemplatesDir, name+".yml")
|
||||||
if io.Local.IsFile(templatePath) {
|
if io.Local.IsFile(templatePath) {
|
||||||
content, err := io.Local.Read(templatePath)
|
content, err := io.Local.Read(templatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -116,10 +104,6 @@ func GetTemplate(name string) (string, error) {
|
||||||
// It supports two syntaxes:
|
// It supports two syntaxes:
|
||||||
// - ${VAR} - required variable, returns error if not provided
|
// - ${VAR} - required variable, returns error if not provided
|
||||||
// - ${VAR:-default} - variable with default value
|
// - ${VAR:-default} - variable with default value
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// content, err := ApplyTemplate("core-dev", vars)
|
|
||||||
func ApplyTemplate(name string, vars map[string]string) (string, error) {
|
func ApplyTemplate(name string, vars map[string]string) (string, error) {
|
||||||
content, err := GetTemplate(name)
|
content, err := GetTemplate(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -133,10 +117,6 @@ func ApplyTemplate(name string, vars map[string]string) (string, error) {
|
||||||
// It supports two syntaxes:
|
// It supports two syntaxes:
|
||||||
// - ${VAR} - required variable, returns error if not provided
|
// - ${VAR} - required variable, returns error if not provided
|
||||||
// - ${VAR:-default} - variable with default value
|
// - ${VAR:-default} - variable with default value
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// content, err := ApplyVariables(raw, vars)
|
|
||||||
func ApplyVariables(content string, vars map[string]string) (string, error) {
|
func ApplyVariables(content string, vars map[string]string) (string, error) {
|
||||||
// Pattern for ${VAR:-default} syntax
|
// Pattern for ${VAR:-default} syntax
|
||||||
defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`)
|
defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`)
|
||||||
|
|
@ -178,7 +158,7 @@ func ApplyVariables(content string, vars map[string]string) (string, error) {
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(missingVars) > 0 {
|
if len(missingVars) > 0 {
|
||||||
return "", coreerr.E("ApplyVariables", core.Concat("missing required variables: ", core.Join(", ", missingVars...)), nil)
|
return "", coreerr.E("ApplyVariables", "missing required variables: "+strings.Join(missingVars, ", "), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
@ -186,10 +166,6 @@ func ApplyVariables(content string, vars map[string]string) (string, error) {
|
||||||
|
|
||||||
// ExtractVariables extracts all variable names from a template.
|
// ExtractVariables extracts all variable names from a template.
|
||||||
// Returns two slices: required variables and optional variables (with defaults).
|
// Returns two slices: required variables and optional variables (with defaults).
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// required, optional := ExtractVariables(content)
|
|
||||||
func ExtractVariables(content string) (required []string, optional map[string]string) {
|
func ExtractVariables(content string) (required []string, optional map[string]string) {
|
||||||
optional = make(map[string]string)
|
optional = make(map[string]string)
|
||||||
requiredSet := make(map[string]bool)
|
requiredSet := make(map[string]bool)
|
||||||
|
|
@ -230,18 +206,21 @@ func ExtractVariables(content string) (required []string, optional map[string]st
|
||||||
// Returns empty string if the directory doesn't exist.
|
// Returns empty string if the directory doesn't exist.
|
||||||
func getUserTemplatesDir() string {
|
func getUserTemplatesDir() string {
|
||||||
// Try workspace-relative .core/linuxkit first
|
// Try workspace-relative .core/linuxkit first
|
||||||
wsDir := coreutil.JoinPath(coreutil.CurrentDir(), ".core", "linuxkit")
|
cwd, err := os.Getwd()
|
||||||
if io.Local.IsDir(wsDir) {
|
if err == nil {
|
||||||
return wsDir
|
wsDir := filepath.Join(cwd, ".core", "linuxkit")
|
||||||
|
if io.Local.IsDir(wsDir) {
|
||||||
|
return wsDir
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try home directory
|
// Try home directory
|
||||||
home := coreutil.HomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if home == "" {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
homeDir := coreutil.JoinPath(home, ".core", "linuxkit")
|
homeDir := filepath.Join(home, ".core", "linuxkit")
|
||||||
if io.Local.IsDir(homeDir) {
|
if io.Local.IsDir(homeDir) {
|
||||||
return homeDir
|
return homeDir
|
||||||
}
|
}
|
||||||
|
|
@ -264,12 +243,12 @@ func scanUserTemplates(dir string) []Template {
|
||||||
}
|
}
|
||||||
|
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
if !core.HasSuffix(name, ".yml") && !core.HasSuffix(name, ".yaml") {
|
if !strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract template name from filename
|
// Extract template name from filename
|
||||||
templateName := core.TrimSuffix(core.TrimSuffix(name, ".yml"), ".yaml")
|
templateName := strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml")
|
||||||
|
|
||||||
// Skip if this is a builtin template name (embedded takes precedence)
|
// Skip if this is a builtin template name (embedded takes precedence)
|
||||||
isBuiltin := false
|
isBuiltin := false
|
||||||
|
|
@ -284,7 +263,7 @@ func scanUserTemplates(dir string) []Template {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read file to extract description from comments
|
// Read file to extract description from comments
|
||||||
description := extractTemplateDescription(coreutil.JoinPath(dir, name))
|
description := extractTemplateDescription(filepath.Join(dir, name))
|
||||||
if description == "" {
|
if description == "" {
|
||||||
description = "User-defined template"
|
description = "User-defined template"
|
||||||
}
|
}
|
||||||
|
|
@ -292,7 +271,7 @@ func scanUserTemplates(dir string) []Template {
|
||||||
templates = append(templates, Template{
|
templates = append(templates, Template{
|
||||||
Name: templateName,
|
Name: templateName,
|
||||||
Description: description,
|
Description: description,
|
||||||
Path: coreutil.JoinPath(dir, name),
|
Path: filepath.Join(dir, name),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,14 +286,14 @@ func extractTemplateDescription(path string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := core.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
var descLines []string
|
var descLines []string
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := core.Trim(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if core.HasPrefix(trimmed, "#") {
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
// Remove the # and trim
|
// Remove the # and trim
|
||||||
comment := core.Trim(core.TrimPrefix(trimmed, "#"))
|
comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||||
if comment != "" {
|
if comment != "" {
|
||||||
descLines = append(descLines, comment)
|
descLines = append(descLines, comment)
|
||||||
// Only take the first meaningful comment line as description
|
// Only take the first meaningful comment line as description
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"dappco.re/go/core/io"
|
|
||||||
|
|
||||||
"dappco.re/go/core/container/internal/coreutil"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTemplates_ListTemplates_Good(t *testing.T) {
|
func TestListTemplates_Good(t *testing.T) {
|
||||||
templates := ListTemplates()
|
templates := ListTemplates()
|
||||||
|
|
||||||
// Should have at least the builtin templates
|
// Should have at least the builtin templates
|
||||||
|
|
@ -42,7 +41,7 @@ func TestTemplates_ListTemplates_Good(t *testing.T) {
|
||||||
assert.True(t, found, "server-php template should exist")
|
assert.True(t, found, "server-php template should exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetTemplate_CoreDev_Good(t *testing.T) {
|
func TestGetTemplate_Good_CoreDev(t *testing.T) {
|
||||||
content, err := GetTemplate("core-dev")
|
content, err := GetTemplate("core-dev")
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -53,7 +52,7 @@ func TestGetTemplate_CoreDev_Good(t *testing.T) {
|
||||||
assert.Contains(t, content, "services:")
|
assert.Contains(t, content, "services:")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetTemplate_ServerPhp_Good(t *testing.T) {
|
func TestGetTemplate_Good_ServerPhp(t *testing.T) {
|
||||||
content, err := GetTemplate("server-php")
|
content, err := GetTemplate("server-php")
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -64,14 +63,14 @@ func TestGetTemplate_ServerPhp_Good(t *testing.T) {
|
||||||
assert.Contains(t, content, "${DOMAIN:-localhost}")
|
assert.Contains(t, content, "${DOMAIN:-localhost}")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetTemplate_NotFound_Bad(t *testing.T) {
|
func TestGetTemplate_Bad_NotFound(t *testing.T) {
|
||||||
_, err := GetTemplate("nonexistent-template")
|
_, err := GetTemplate("nonexistent-template")
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "template not found")
|
assert.Contains(t, err.Error(), "template not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyVariables_SimpleSubstitution_Good(t *testing.T) {
|
func TestApplyVariables_Good_SimpleSubstitution(t *testing.T) {
|
||||||
content := "Hello ${NAME}, welcome to ${PLACE}!"
|
content := "Hello ${NAME}, welcome to ${PLACE}!"
|
||||||
vars := map[string]string{
|
vars := map[string]string{
|
||||||
"NAME": "World",
|
"NAME": "World",
|
||||||
|
|
@ -84,7 +83,7 @@ func TestApplyVariables_SimpleSubstitution_Good(t *testing.T) {
|
||||||
assert.Equal(t, "Hello World, welcome to Core!", result)
|
assert.Equal(t, "Hello World, welcome to Core!", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyVariables_WithDefaults_Good(t *testing.T) {
|
func TestApplyVariables_Good_WithDefaults(t *testing.T) {
|
||||||
content := "Memory: ${MEMORY:-1024}MB, CPUs: ${CPUS:-2}"
|
content := "Memory: ${MEMORY:-1024}MB, CPUs: ${CPUS:-2}"
|
||||||
vars := map[string]string{
|
vars := map[string]string{
|
||||||
"MEMORY": "2048",
|
"MEMORY": "2048",
|
||||||
|
|
@ -97,7 +96,7 @@ func TestApplyVariables_WithDefaults_Good(t *testing.T) {
|
||||||
assert.Equal(t, "Memory: 2048MB, CPUs: 2", result)
|
assert.Equal(t, "Memory: 2048MB, CPUs: 2", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyVariables_AllDefaults_Good(t *testing.T) {
|
func TestApplyVariables_Good_AllDefaults(t *testing.T) {
|
||||||
content := "${HOST:-localhost}:${PORT:-8080}"
|
content := "${HOST:-localhost}:${PORT:-8080}"
|
||||||
vars := map[string]string{} // No vars provided
|
vars := map[string]string{} // No vars provided
|
||||||
|
|
||||||
|
|
@ -107,7 +106,7 @@ func TestApplyVariables_AllDefaults_Good(t *testing.T) {
|
||||||
assert.Equal(t, "localhost:8080", result)
|
assert.Equal(t, "localhost:8080", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyVariables_MixedSyntax_Good(t *testing.T) {
|
func TestApplyVariables_Good_MixedSyntax(t *testing.T) {
|
||||||
content := `
|
content := `
|
||||||
hostname: ${HOSTNAME:-myhost}
|
hostname: ${HOSTNAME:-myhost}
|
||||||
ssh_key: ${SSH_KEY}
|
ssh_key: ${SSH_KEY}
|
||||||
|
|
@ -126,7 +125,7 @@ memory: ${MEMORY:-512}
|
||||||
assert.Contains(t, result, "memory: 512")
|
assert.Contains(t, result, "memory: 512")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyVariables_EmptyDefault_Good(t *testing.T) {
|
func TestApplyVariables_Good_EmptyDefault(t *testing.T) {
|
||||||
content := "value: ${OPT:-}"
|
content := "value: ${OPT:-}"
|
||||||
vars := map[string]string{}
|
vars := map[string]string{}
|
||||||
|
|
||||||
|
|
@ -136,7 +135,7 @@ func TestApplyVariables_EmptyDefault_Good(t *testing.T) {
|
||||||
assert.Equal(t, "value: ", result)
|
assert.Equal(t, "value: ", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyVariables_MissingRequired_Bad(t *testing.T) {
|
func TestApplyVariables_Bad_MissingRequired(t *testing.T) {
|
||||||
content := "SSH Key: ${SSH_KEY}"
|
content := "SSH Key: ${SSH_KEY}"
|
||||||
vars := map[string]string{} // Missing required SSH_KEY
|
vars := map[string]string{} // Missing required SSH_KEY
|
||||||
|
|
||||||
|
|
@ -147,7 +146,7 @@ func TestApplyVariables_MissingRequired_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "SSH_KEY")
|
assert.Contains(t, err.Error(), "SSH_KEY")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyVariables_MultipleMissing_Bad(t *testing.T) {
|
func TestApplyVariables_Bad_MultipleMissing(t *testing.T) {
|
||||||
content := "${VAR1} and ${VAR2} and ${VAR3}"
|
content := "${VAR1} and ${VAR2} and ${VAR3}"
|
||||||
vars := map[string]string{
|
vars := map[string]string{
|
||||||
"VAR2": "provided",
|
"VAR2": "provided",
|
||||||
|
|
@ -159,10 +158,10 @@ func TestApplyVariables_MultipleMissing_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "missing required variables")
|
assert.Contains(t, err.Error(), "missing required variables")
|
||||||
// Should mention both missing vars
|
// Should mention both missing vars
|
||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
assert.True(t, core.Contains(errStr, "VAR1") || core.Contains(errStr, "VAR3"))
|
assert.True(t, strings.Contains(errStr, "VAR1") || strings.Contains(errStr, "VAR3"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTemplates_ApplyTemplate_Good(t *testing.T) {
|
func TestApplyTemplate_Good(t *testing.T) {
|
||||||
vars := map[string]string{
|
vars := map[string]string{
|
||||||
"SSH_KEY": "ssh-rsa AAAA... user@host",
|
"SSH_KEY": "ssh-rsa AAAA... user@host",
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +175,7 @@ func TestTemplates_ApplyTemplate_Good(t *testing.T) {
|
||||||
assert.Contains(t, result, "core-dev") // HOSTNAME default
|
assert.Contains(t, result, "core-dev") // HOSTNAME default
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyTemplate_TemplateNotFound_Bad(t *testing.T) {
|
func TestApplyTemplate_Bad_TemplateNotFound(t *testing.T) {
|
||||||
vars := map[string]string{
|
vars := map[string]string{
|
||||||
"SSH_KEY": "test",
|
"SSH_KEY": "test",
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +186,7 @@ func TestApplyTemplate_TemplateNotFound_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "template not found")
|
assert.Contains(t, err.Error(), "template not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyTemplate_MissingVariable_Bad(t *testing.T) {
|
func TestApplyTemplate_Bad_MissingVariable(t *testing.T) {
|
||||||
// server-php requires SSH_KEY
|
// server-php requires SSH_KEY
|
||||||
vars := map[string]string{} // Missing required SSH_KEY
|
vars := map[string]string{} // Missing required SSH_KEY
|
||||||
|
|
||||||
|
|
@ -197,7 +196,7 @@ func TestApplyTemplate_MissingVariable_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "missing required variables")
|
assert.Contains(t, err.Error(), "missing required variables")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTemplates_ExtractVariables_Good(t *testing.T) {
|
func TestExtractVariables_Good(t *testing.T) {
|
||||||
content := `
|
content := `
|
||||||
hostname: ${HOSTNAME:-myhost}
|
hostname: ${HOSTNAME:-myhost}
|
||||||
ssh_key: ${SSH_KEY}
|
ssh_key: ${SSH_KEY}
|
||||||
|
|
@ -219,7 +218,7 @@ api_key: ${API_KEY}
|
||||||
assert.Len(t, optional, 3)
|
assert.Len(t, optional, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractVariables_NoVariables_Good(t *testing.T) {
|
func TestExtractVariables_Good_NoVariables(t *testing.T) {
|
||||||
content := "This has no variables at all"
|
content := "This has no variables at all"
|
||||||
|
|
||||||
required, optional := ExtractVariables(content)
|
required, optional := ExtractVariables(content)
|
||||||
|
|
@ -228,7 +227,7 @@ func TestExtractVariables_NoVariables_Good(t *testing.T) {
|
||||||
assert.Empty(t, optional)
|
assert.Empty(t, optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractVariables_OnlyDefaults_Good(t *testing.T) {
|
func TestExtractVariables_Good_OnlyDefaults(t *testing.T) {
|
||||||
content := "${A:-default1} ${B:-default2}"
|
content := "${A:-default1} ${B:-default2}"
|
||||||
|
|
||||||
required, optional := ExtractVariables(content)
|
required, optional := ExtractVariables(content)
|
||||||
|
|
@ -239,7 +238,7 @@ func TestExtractVariables_OnlyDefaults_Good(t *testing.T) {
|
||||||
assert.Equal(t, "default2", optional["B"])
|
assert.Equal(t, "default2", optional["B"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTemplates_ScanUserTemplates_Good(t *testing.T) {
|
func TestScanUserTemplates_Good(t *testing.T) {
|
||||||
// Create a temporary directory with template files
|
// Create a temporary directory with template files
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
|
@ -249,11 +248,11 @@ func TestTemplates_ScanUserTemplates_Good(t *testing.T) {
|
||||||
kernel:
|
kernel:
|
||||||
image: linuxkit/kernel:6.6
|
image: linuxkit/kernel:6.6
|
||||||
`
|
`
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "custom.yml"), templateContent)
|
err := os.WriteFile(filepath.Join(tmpDir, "custom.yml"), []byte(templateContent), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create a non-template file (should be ignored)
|
// Create a non-template file (should be ignored)
|
||||||
err = io.Local.Write(coreutil.JoinPath(tmpDir, "readme.txt"), "Not a template")
|
err = os.WriteFile(filepath.Join(tmpDir, "readme.txt"), []byte("Not a template"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
templates := scanUserTemplates(tmpDir)
|
templates := scanUserTemplates(tmpDir)
|
||||||
|
|
@ -263,13 +262,13 @@ kernel:
|
||||||
assert.Equal(t, "My Custom Template", templates[0].Description)
|
assert.Equal(t, "My Custom Template", templates[0].Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanUserTemplates_MultipleTemplates_Good(t *testing.T) {
|
func TestScanUserTemplates_Good_MultipleTemplates(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
// Create multiple template files
|
// Create multiple template files
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "web.yml"), "# Web Server\nkernel:")
|
err := os.WriteFile(filepath.Join(tmpDir, "web.yml"), []byte("# Web Server\nkernel:"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = io.Local.Write(coreutil.JoinPath(tmpDir, "db.yaml"), "# Database Server\nkernel:")
|
err = os.WriteFile(filepath.Join(tmpDir, "db.yaml"), []byte("# Database Server\nkernel:"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
templates := scanUserTemplates(tmpDir)
|
templates := scanUserTemplates(tmpDir)
|
||||||
|
|
@ -285,7 +284,7 @@ func TestScanUserTemplates_MultipleTemplates_Good(t *testing.T) {
|
||||||
assert.True(t, names["db"])
|
assert.True(t, names["db"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanUserTemplates_EmptyDirectory_Good(t *testing.T) {
|
func TestScanUserTemplates_Good_EmptyDirectory(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
templates := scanUserTemplates(tmpDir)
|
templates := scanUserTemplates(tmpDir)
|
||||||
|
|
@ -293,22 +292,22 @@ func TestScanUserTemplates_EmptyDirectory_Good(t *testing.T) {
|
||||||
assert.Empty(t, templates)
|
assert.Empty(t, templates)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanUserTemplates_NonexistentDirectory_Bad(t *testing.T) {
|
func TestScanUserTemplates_Bad_NonexistentDirectory(t *testing.T) {
|
||||||
templates := scanUserTemplates("/nonexistent/path/to/templates")
|
templates := scanUserTemplates("/nonexistent/path/to/templates")
|
||||||
|
|
||||||
assert.Empty(t, templates)
|
assert.Empty(t, templates)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTemplates_ExtractTemplateDescription_Good(t *testing.T) {
|
func TestExtractTemplateDescription_Good(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
path := coreutil.JoinPath(tmpDir, "test.yml")
|
path := filepath.Join(tmpDir, "test.yml")
|
||||||
|
|
||||||
content := `# My Template Description
|
content := `# My Template Description
|
||||||
# More details here
|
# More details here
|
||||||
kernel:
|
kernel:
|
||||||
image: test
|
image: test
|
||||||
`
|
`
|
||||||
err := io.Local.Write(path, content)
|
err := os.WriteFile(path, []byte(content), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
desc := extractTemplateDescription(path)
|
desc := extractTemplateDescription(path)
|
||||||
|
|
@ -316,14 +315,14 @@ kernel:
|
||||||
assert.Equal(t, "My Template Description", desc)
|
assert.Equal(t, "My Template Description", desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractTemplateDescription_NoComments_Good(t *testing.T) {
|
func TestExtractTemplateDescription_Good_NoComments(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
path := coreutil.JoinPath(tmpDir, "test.yml")
|
path := filepath.Join(tmpDir, "test.yml")
|
||||||
|
|
||||||
content := `kernel:
|
content := `kernel:
|
||||||
image: test
|
image: test
|
||||||
`
|
`
|
||||||
err := io.Local.Write(path, content)
|
err := os.WriteFile(path, []byte(content), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
desc := extractTemplateDescription(path)
|
desc := extractTemplateDescription(path)
|
||||||
|
|
@ -331,13 +330,13 @@ func TestExtractTemplateDescription_NoComments_Good(t *testing.T) {
|
||||||
assert.Empty(t, desc)
|
assert.Empty(t, desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractTemplateDescription_FileNotFound_Bad(t *testing.T) {
|
func TestExtractTemplateDescription_Bad_FileNotFound(t *testing.T) {
|
||||||
desc := extractTemplateDescription("/nonexistent/file.yml")
|
desc := extractTemplateDescription("/nonexistent/file.yml")
|
||||||
|
|
||||||
assert.Empty(t, desc)
|
assert.Empty(t, desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTemplates_VariablePatternEdgeCases_Good(t *testing.T) {
|
func TestVariablePatternEdgeCases_Good(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
content string
|
content string
|
||||||
|
|
@ -385,15 +384,15 @@ func TestTemplates_VariablePatternEdgeCases_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanUserTemplates_SkipsBuiltinNames_Good(t *testing.T) {
|
func TestScanUserTemplates_Good_SkipsBuiltinNames(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
// Create a template with a builtin name (should be skipped)
|
// Create a template with a builtin name (should be skipped)
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "core-dev.yml"), "# Duplicate\nkernel:")
|
err := os.WriteFile(filepath.Join(tmpDir, "core-dev.yml"), []byte("# Duplicate\nkernel:"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create a unique template
|
// Create a unique template
|
||||||
err = io.Local.Write(coreutil.JoinPath(tmpDir, "unique.yml"), "# Unique\nkernel:")
|
err = os.WriteFile(filepath.Join(tmpDir, "unique.yml"), []byte("# Unique\nkernel:"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
templates := scanUserTemplates(tmpDir)
|
templates := scanUserTemplates(tmpDir)
|
||||||
|
|
@ -403,15 +402,15 @@ func TestScanUserTemplates_SkipsBuiltinNames_Good(t *testing.T) {
|
||||||
assert.Equal(t, "unique", templates[0].Name)
|
assert.Equal(t, "unique", templates[0].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanUserTemplates_SkipsDirectories_Good(t *testing.T) {
|
func TestScanUserTemplates_Good_SkipsDirectories(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
// Create a subdirectory (should be skipped)
|
// Create a subdirectory (should be skipped)
|
||||||
err := io.Local.EnsureDir(coreutil.JoinPath(tmpDir, "subdir"))
|
err := os.MkdirAll(filepath.Join(tmpDir, "subdir"), 0755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create a valid template
|
// Create a valid template
|
||||||
err = io.Local.Write(coreutil.JoinPath(tmpDir, "valid.yml"), "# Valid\nkernel:")
|
err = os.WriteFile(filepath.Join(tmpDir, "valid.yml"), []byte("# Valid\nkernel:"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
templates := scanUserTemplates(tmpDir)
|
templates := scanUserTemplates(tmpDir)
|
||||||
|
|
@ -420,13 +419,13 @@ func TestScanUserTemplates_SkipsDirectories_Good(t *testing.T) {
|
||||||
assert.Equal(t, "valid", templates[0].Name)
|
assert.Equal(t, "valid", templates[0].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanUserTemplates_YamlExtension_Good(t *testing.T) {
|
func TestScanUserTemplates_Good_YamlExtension(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
// Create templates with both extensions
|
// Create templates with both extensions
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "template1.yml"), "# Template 1\nkernel:")
|
err := os.WriteFile(filepath.Join(tmpDir, "template1.yml"), []byte("# Template 1\nkernel:"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = io.Local.Write(coreutil.JoinPath(tmpDir, "template2.yaml"), "# Template 2\nkernel:")
|
err = os.WriteFile(filepath.Join(tmpDir, "template2.yaml"), []byte("# Template 2\nkernel:"), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
templates := scanUserTemplates(tmpDir)
|
templates := scanUserTemplates(tmpDir)
|
||||||
|
|
@ -441,9 +440,9 @@ func TestScanUserTemplates_YamlExtension_Good(t *testing.T) {
|
||||||
assert.True(t, names["template2"])
|
assert.True(t, names["template2"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractTemplateDescription_EmptyComment_Good(t *testing.T) {
|
func TestExtractTemplateDescription_Good_EmptyComment(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
path := coreutil.JoinPath(tmpDir, "test.yml")
|
path := filepath.Join(tmpDir, "test.yml")
|
||||||
|
|
||||||
// First comment is empty, second has content
|
// First comment is empty, second has content
|
||||||
content := `#
|
content := `#
|
||||||
|
|
@ -451,7 +450,7 @@ func TestExtractTemplateDescription_EmptyComment_Good(t *testing.T) {
|
||||||
kernel:
|
kernel:
|
||||||
image: test
|
image: test
|
||||||
`
|
`
|
||||||
err := io.Local.Write(path, content)
|
err := os.WriteFile(path, []byte(content), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
desc := extractTemplateDescription(path)
|
desc := extractTemplateDescription(path)
|
||||||
|
|
@ -459,9 +458,9 @@ kernel:
|
||||||
assert.Equal(t, "Actual description here", desc)
|
assert.Equal(t, "Actual description here", desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractTemplateDescription_MultipleEmptyComments_Good(t *testing.T) {
|
func TestExtractTemplateDescription_Good_MultipleEmptyComments(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
path := coreutil.JoinPath(tmpDir, "test.yml")
|
path := filepath.Join(tmpDir, "test.yml")
|
||||||
|
|
||||||
// Multiple empty comments before actual content
|
// Multiple empty comments before actual content
|
||||||
content := `#
|
content := `#
|
||||||
|
|
@ -471,7 +470,7 @@ func TestExtractTemplateDescription_MultipleEmptyComments_Good(t *testing.T) {
|
||||||
kernel:
|
kernel:
|
||||||
image: test
|
image: test
|
||||||
`
|
`
|
||||||
err := io.Local.Write(path, content)
|
err := os.WriteFile(path, []byte(content), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
desc := extractTemplateDescription(path)
|
desc := extractTemplateDescription(path)
|
||||||
|
|
@ -479,14 +478,14 @@ kernel:
|
||||||
assert.Equal(t, "Real description", desc)
|
assert.Equal(t, "Real description", desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanUserTemplates_DefaultDescription_Good(t *testing.T) {
|
func TestScanUserTemplates_Good_DefaultDescription(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
// Create a template without comments
|
// Create a template without comments
|
||||||
content := `kernel:
|
content := `kernel:
|
||||||
image: test
|
image: test
|
||||||
`
|
`
|
||||||
err := io.Local.Write(coreutil.JoinPath(tmpDir, "nocomment.yml"), content)
|
err := os.WriteFile(filepath.Join(tmpDir, "nocomment.yml"), []byte(content), 0644)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
templates := scanUserTemplates(tmpDir)
|
templates := scanUserTemplates(tmpDir)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue