Compare commits

...

6 commits
v0.1.7 ... dev

Author SHA1 Message Date
Virgil
6ee40b66b6 chore: verification pass 2026-03-27 03:12:04 +00:00
Virgil
4021325d10 chore: polish ax v0.8.0 compliance
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 17:18:44 +00:00
Virgil
ba8b3df12c refactor(container): align with core v0.8.0-alpha.1
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 15:12:11 +00:00
Virgil
37ccb287c0 docs(upgrade): add v0.8.0 upgrade report
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 13:45:08 +00:00
1a7b6a2631 Merge pull request '[agent/claude] Migrate module path to dappco.re/go/core/container. Update g...' (#1) from agent/migrate-module-path-to-dappco-re-go-core into main 2026-03-22 01:55:59 +00:00
Snider
6c4050486b refactor(container): migrate module path to dappco.re/go/core/container
Update module path from forge.lthn.ai/core/go-container to
dappco.re/go/core/container. Migrate import paths for go-io (v0.2.0),
go-log (v0.1.0), and go-i18n (v0.2.0) to their new dappco.re
equivalents. cli and config remain at forge.lthn.ai (not yet migrated).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 01:55:36 +00:00
44 changed files with 1999 additions and 948 deletions

View file

@ -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: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (edge cases) - Tests use testify; naming convention: `TestSubject_Function_{Good,Bad,Ugly}`
- Error wrapping: `fmt.Errorf("context: %w", err)` - Error wrapping: `core.E("Op", "message", 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

View file

@ -1,6 +1,6 @@
# Consumers of go-container # Consumers of go-container
These modules import `forge.lthn.ai/core/go-container`: These modules import `dappco.re/go/core/container`:
- core - core
- go-devops - go-devops

436
UPGRADE.md Normal file
View file

@ -0,0 +1,436 @@
# 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.

View file

@ -2,18 +2,17 @@ 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 (
@ -82,12 +81,12 @@ func runContainer(image, name string, detach bool, memory, cpus, sshPort int) er
SSHPort: sshPort, SSHPort: sshPort,
} }
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("image")), image) core.Print(nil, "%s %s", dimStyle.Render(i18n.Label("image")), image)
if name != "" { if 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.name")), name)
} }
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name()) core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
fmt.Println() core.Println()
ctx := context.Background() ctx := context.Background()
c, err := manager.Run(ctx, image, opts) c, err := manager.Run(ctx, image, opts)
@ -96,13 +95,14 @@ func runContainer(image, name string, detach bool, memory, cpus, sshPort int) er
} }
if detach { if detach {
fmt.Printf("%s %s\n", successStyle.Render(i18n.Label("started")), c.ID) core.Print(nil, "%s %s", successStyle.Render(i18n.Label("started")), c.ID)
fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID) core.Print(nil, "%s %d", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
fmt.Println() core.Println()
fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]})) core.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]}))
fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]})) core.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]}))
} else { } else {
fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID) core.Println()
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 {
fmt.Println(i18n.T("cmd.vm.ps.no_containers")) core.Println(i18n.T("cmd.vm.ps.no_containers"))
} else { } else {
fmt.Println(i18n.T("cmd.vm.ps.no_running")) core.Println(i18n.T("cmd.vm.ps.no_running"))
} }
return nil return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(proc.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, i18n.T("cmd.vm.ps.header")) core.Print(w, "%s", i18n.T("cmd.vm.ps.header"))
_, _ = fmt.Fprintln(w, "--\t----\t-----\t------\t-------\t---") core.Print(w, "%s", "--\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)
} }
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", core.Print(w, "%s\t%s\t%s\t%s\t%s\t%d",
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 fmt.Sprintf("%ds", int(d.Seconds())) return core.Sprintf("%ds", int(d.Seconds()))
} }
if d < time.Hour { if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes())) return core.Sprintf("%dm", int(d.Minutes()))
} }
if d < 24*time.Hour { if d < 24*time.Hour {
return fmt.Sprintf("%dh", int(d.Hours())) return core.Sprintf("%dh", int(d.Hours()))
} }
return fmt.Sprintf("%dd", int(d.Hours()/24)) return core.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
} }
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.stop.stopping")), fullID[:8]) core.Print(nil, "%s %s", 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)
} }
fmt.Printf("%s\n", successStyle.Render(i18n.T("common.status.stopped"))) core.Print(nil, "%s", 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 strings.HasPrefix(c.ID, partialID) || strings.HasPrefix(c.Name, partialID) { if core.HasPrefix(c.ID, partialID) || core.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(os.Stdout, reader) _, err = goio.Copy(proc.Stdout, reader)
return err return err
} }

View file

@ -2,18 +2,16 @@ 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.
@ -72,29 +70,30 @@ func listTemplates() error {
templates := container.ListTemplates() templates := container.ListTemplates()
if len(templates) == 0 { if len(templates) == 0 {
fmt.Println(i18n.T("cmd.vm.templates.no_templates")) core.Println(i18n.T("cmd.vm.templates.no_templates"))
return nil return nil
} }
fmt.Printf("%s\n\n", repoNameStyle.Render(i18n.T("cmd.vm.templates.title"))) core.Print(nil, "%s", repoNameStyle.Render(i18n.T("cmd.vm.templates.title")))
core.Println()
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(proc.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, i18n.T("cmd.vm.templates.header")) core.Print(w, "%s", i18n.T("cmd.vm.templates.header"))
_, _ = fmt.Fprintln(w, "----\t-----------") core.Print(w, "%s", "----\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] + "..."
} }
_, _ = fmt.Fprintf(w, "%s\t%s\n", repoNameStyle.Render(tmpl.Name), desc) core.Print(w, "%s\t%s", repoNameStyle.Render(tmpl.Name), desc)
} }
_ = w.Flush() _ = w.Flush()
fmt.Println() core.Println()
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.show"), dimStyle.Render("core vm templates show <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.vars"), dimStyle.Render("core vm templates vars <name>"))
fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.run"), dimStyle.Render("core vm run --template <name> --var SSH_KEY=\"...\"")) core.Print(nil, "%s %s", i18n.T("cmd.vm.templates.hint.run"), dimStyle.Render("core vm run --template <name> --var SSH_KEY=\"...\""))
return nil return nil
} }
@ -105,8 +104,9 @@ func showTemplate(name string) error {
return err return err
} }
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name)) core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name))
fmt.Println(content) core.Println()
core.Println(content)
return nil return nil
} }
@ -119,34 +119,39 @@ func showTemplateVars(name string) error {
required, optional := container.ExtractVariables(content) required, optional := container.ExtractVariables(content)
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name)) core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name))
core.Println()
if len(required) > 0 { if len(required) > 0 {
fmt.Printf("%s\n", errorStyle.Render(i18n.T("cmd.vm.templates.vars.required"))) core.Print(nil, "%s", errorStyle.Render(i18n.T("cmd.vm.templates.vars.required")))
for _, v := range required { for _, v := range required {
fmt.Printf(" %s\n", varStyle.Render("${"+v+"}")) core.Print(nil, " %s", varStyle.Render("${"+v+"}"))
} }
fmt.Println() core.Println()
} }
if len(optional) > 0 { if len(optional) > 0 {
fmt.Printf("%s\n", successStyle.Render(i18n.T("cmd.vm.templates.vars.optional"))) core.Print(nil, "%s", successStyle.Render(i18n.T("cmd.vm.templates.vars.optional")))
for v, def := range optional { for v, def := range optional {
fmt.Printf(" %s = %s\n", core.Print(nil, " %s = %s",
varStyle.Render("${"+v+"}"), varStyle.Render("${"+v+"}"),
defaultStyle.Render(def)) defaultStyle.Render(def))
} }
fmt.Println() core.Println()
} }
if len(required) == 0 && len(optional) == 0 { if len(required) == 0 && len(optional) == 0 {
fmt.Println(i18n.T("cmd.vm.templates.vars.none")) core.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)
@ -155,23 +160,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 := os.MkdirTemp("", "core-linuxkit-*") tmpDir, err := coreutil.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() { _ = os.RemoveAll(tmpDir) }() defer func() { _ = io.Local.DeleteAll(tmpDir) }()
// Write the YAML file // Write the YAML file
yamlPath := filepath.Join(tmpDir, templateName+".yml") yamlPath := coreutil.JoinPath(tmpDir, core.Concat(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)
} }
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(templateName)) core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(templateName))
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.building")), yamlPath) core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.building")), yamlPath)
// Build the image using linuxkit // Build the image using linuxkit
outputPath := filepath.Join(tmpDir, templateName) outputPath := coreutil.JoinPath(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)
} }
@ -182,8 +187,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)
} }
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("common.label.image")), imagePath) core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.image")), imagePath)
fmt.Println() core.Println()
// Run the image // Run the image
manager, err := container.NewLinuxKitManager(io.Local) manager, err := container.NewLinuxKitManager(io.Local)
@ -191,8 +196,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)
} }
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name()) core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
fmt.Println() core.Println()
ctx := context.Background() ctx := context.Background()
c, err := manager.Run(ctx, imagePath, runOpts) c, err := manager.Run(ctx, imagePath, runOpts)
@ -201,13 +206,14 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai
} }
if runOpts.Detach { if runOpts.Detach {
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("common.label.started")), c.ID) core.Print(nil, "%s %s", successStyle.Render(i18n.T("common.label.started")), c.ID)
fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID) core.Print(nil, "%s %d", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
fmt.Println() core.Println()
fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]})) core.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]}))
fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]})) core.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]}))
} else { } else {
fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID) core.Println()
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
} }
return nil return nil
@ -223,13 +229,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 := exec.Command(lkPath, "build", cmd := proc.NewCommand(lkPath, "build",
"--format", "iso-bios", "--format", "iso-bios",
"--name", outputPath, "--name", outputPath,
yamlPath) yamlPath)
cmd.Stdout = os.Stdout cmd.Stdout = proc.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = proc.Stderr
return cmd.Run() return cmd.Run()
} }
@ -240,27 +246,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 := basePath + ext path := core.Concat(basePath, ext)
if _, err := os.Stat(path); err == nil { if io.Local.IsFile(path) {
return path return path
} }
} }
// Check directory for any image file // Check directory for any image file
dir := filepath.Dir(basePath) dir := core.PathDir(basePath)
base := filepath.Base(basePath) base := core.PathBase(basePath)
entries, err := os.ReadDir(dir) entries, err := io.Local.List(dir)
if err != nil { if err != nil {
return "" return ""
} }
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
if strings.HasPrefix(name, base) { if core.HasPrefix(name, base) {
for _, ext := range []string{".iso", ".qcow2", ".raw", ".vmdk"} { for _, ext := range []string{".iso", ".qcow2", ".raw", ".vmdk"} {
if strings.HasSuffix(name, ext) { if core.HasSuffix(name, ext) {
return filepath.Join(dir, name) return coreutil.JoinPath(dir, name)
} }
} }
} }
@ -272,7 +278,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 := exec.LookPath("linuxkit"); err == nil { if path, err := proc.LookPath("linuxkit"); err == nil {
return path, nil return path, nil
} }
@ -283,7 +289,7 @@ func lookupLinuxKit() (string, error) {
} }
for _, p := range paths { for _, p := range paths {
if _, err := os.Stat(p); err == nil { if io.Local.Exists(p) {
return p, nil return p, nil
} }
} }
@ -293,19 +299,36 @@ 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 := strings.SplitN(v, "=", 2) parts := core.SplitN(v, "=", 2)
if len(parts) == 2 { if len(parts) == 2 {
key := strings.TrimSpace(parts[0]) key := core.Trim(parts[0])
value := strings.TrimSpace(parts[1]) value := core.Trim(parts[1])
// Remove surrounding quotes if present // Remove surrounding quotes if present
value = strings.Trim(value, "\"'") value = stripWrappingQuotes(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
}

View file

@ -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,6 +25,10 @@ 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",

View file

@ -81,6 +81,10 @@ 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 {

View file

@ -2,14 +2,13 @@ package devenv
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go-io" core "dappco.re/go/core"
coreerr "forge.lthn.ai/core/go-log" "dappco.re/go/core/io"
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.
@ -27,7 +26,7 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio
return err return err
} }
if !running { if !running {
fmt.Println("Dev environment not running, booting...") core.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)
} }
@ -50,20 +49,22 @@ 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 := os.Getenv("ANTHROPIC_API_KEY"); key != "" { if key := core.Env("ANTHROPIC_API_KEY"); key != "" {
envVars = append(envVars, "ANTHROPIC_API_KEY="+key) envVars = append(envVars, core.Concat("ANTHROPIC_API_KEY=", key))
} }
case "git": case "git":
// Forward git config // Forward git config
name, _ := exec.Command("git", "config", "user.name").Output() name, _ := proc.NewCommand("git", "config", "user.name").Output()
email, _ := exec.Command("git", "config", "user.email").Output() email, _ := proc.NewCommand("git", "config", "user.email").Output()
if len(name) > 0 { if len(name) > 0 {
envVars = append(envVars, "GIT_AUTHOR_NAME="+strings.TrimSpace(string(name))) trimmed := core.Trim(string(name))
envVars = append(envVars, "GIT_COMMITTER_NAME="+strings.TrimSpace(string(name))) envVars = append(envVars, core.Concat("GIT_AUTHOR_NAME=", trimmed))
envVars = append(envVars, core.Concat("GIT_COMMITTER_NAME=", trimmed))
} }
if len(email) > 0 { if len(email) > 0 {
envVars = append(envVars, "GIT_AUTHOR_EMAIL="+strings.TrimSpace(string(email))) trimmed := core.Trim(string(email))
envVars = append(envVars, "GIT_COMMITTER_EMAIL="+strings.TrimSpace(string(email))) envVars = append(envVars, core.Concat("GIT_AUTHOR_EMAIL=", trimmed))
envVars = append(envVars, core.Concat("GIT_COMMITTER_EMAIL=", trimmed))
} }
} }
} }
@ -75,7 +76,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", fmt.Sprintf("%d", DefaultSSHPort), "-p", core.Sprintf("%d", DefaultSSHPort),
} }
args = append(args, "root@localhost") args = append(args, "root@localhost")
@ -88,23 +89,20 @@ 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 := exec.CommandContext(ctx, "ssh", args...) cmd := proc.NewCommandContext(ctx, "ssh", args...)
cmd.Stdin = os.Stdin cmd.Stdin = proc.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = proc.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = proc.Stderr
// Pass environment variables through SSH // Pass environment variables through SSH
for _, env := range envVars { if len(envVars) > 0 {
parts := strings.SplitN(env, "=", 2) cmd.Env = append(proc.Environ(), envVars...)
if len(parts) == 2 {
cmd.Env = append(os.Environ(), env)
}
} }
fmt.Println("Starting Claude in sandboxed environment...") core.Println("Starting Claude in sandboxed environment...")
fmt.Println("Project mounted at /app") core.Println("Project mounted at /app")
fmt.Println("Auth forwarded: SSH agent" + formatAuthList(opts)) core.Println(core.Concat("Auth forwarded: SSH agent", formatAuthList(opts)))
fmt.Println() core.Println()
return cmd.Run() return cmd.Run()
} }
@ -116,27 +114,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 ", " + strings.Join(opts.Auth, ", ") return core.Concat(", ", core.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, err := os.UserHomeDir() home := coreutil.HomeDir()
if err != nil { if home == "" {
return err return coreerr.E("DevOps.CopyGHAuth", "home directory not available", nil)
} }
ghConfigDir := filepath.Join(home, ".config", "gh") ghConfigDir := coreutil.JoinPath(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 := exec.CommandContext(ctx, "scp", cmd := proc.NewCommandContext(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", fmt.Sprintf("%d", DefaultSSHPort), "-P", core.Sprintf("%d", DefaultSSHPort),
"-r", ghConfigDir, "-r", ghConfigDir,
"root@localhost:/root/.config/", "root@localhost:/root/.config/",
) )

View file

@ -6,14 +6,14 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestClaudeOptions_Default(t *testing.T) { func TestClaudeOptions_Default_Good(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(t *testing.T) { func TestClaudeOptions_Custom_Good(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(t *testing.T) {
assert.Equal(t, "opus", opts.Model) assert.Equal(t, "opus", opts.Model)
} }
func TestFormatAuthList_Good_NoAuth(t *testing.T) { func TestFormatAuthList_NoAuth_Good(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_Good_Default(t *testing.T) { func TestFormatAuthList_Default_Good(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_Good_CustomAuth(t *testing.T) { func TestFormatAuthList_CustomAuth_Good(t *testing.T) {
opts := ClaudeOptions{ opts := ClaudeOptions{
Auth: []string{"gh"}, Auth: []string{"gh"},
} }
@ -44,7 +44,7 @@ func TestFormatAuthList_Good_CustomAuth(t *testing.T) {
assert.Equal(t, ", gh", result) assert.Equal(t, ", gh", result)
} }
func TestFormatAuthList_Good_MultipleAuth(t *testing.T) { func TestFormatAuthList_MultipleAuth_Good(t *testing.T) {
opts := ClaudeOptions{ opts := ClaudeOptions{
Auth: []string{"gh", "ssh", "git"}, Auth: []string{"gh", "ssh", "git"},
} }
@ -52,7 +52,7 @@ func TestFormatAuthList_Good_MultipleAuth(t *testing.T) {
assert.Equal(t, ", gh, ssh, git", result) assert.Equal(t, ", gh, ssh, git", result)
} }
func TestFormatAuthList_Good_EmptyAuth(t *testing.T) { func TestFormatAuthList_EmptyAuth_Good(t *testing.T) {
opts := ClaudeOptions{ opts := ClaudeOptions{
Auth: []string{}, Auth: []string{},
} }

View file

@ -1,11 +1,12 @@
package devenv package devenv
import ( import (
"os" "dappco.re/go/core/io"
"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.
@ -38,6 +39,10 @@ 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,
@ -54,16 +59,24 @@ 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, err := os.UserHomeDir() home := coreutil.HomeDir()
if err != nil { if home == "" {
return "", err return "", core.E("ConfigPath", "home directory not available", nil)
} }
return filepath.Join(home, ".core", "config.yaml"), nil return coreutil.JoinPath(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 {

View file

@ -1,35 +1,33 @@
package devenv package devenv
import ( import (
"os" "syscall"
"path/filepath"
"testing" "testing"
"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"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestDefaultConfig(t *testing.T) { func TestConfig_DefaultConfig_Good(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 TestConfigPath(t *testing.T) { func TestConfig_ConfigPath_Good(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 TestLoadConfig_Good(t *testing.T) { func TestConfig_LoadConfig_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)
@ -40,8 +38,8 @@ func TestLoadConfig_Good(t *testing.T) {
tempHome := t.TempDir() tempHome := t.TempDir()
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)
coreDir := filepath.Join(tempHome, ".core") coreDir := coreutil.JoinPath(tempHome, ".core")
err := os.MkdirAll(coreDir, 0755) err := io.Local.EnsureDir(coreDir)
require.NoError(t, err) require.NoError(t, err)
configData := ` configData := `
@ -51,7 +49,7 @@ images:
cdn: cdn:
url: https://cdn.example.com url: https://cdn.example.com
` `
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), configData)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig(io.Local) cfg, err := LoadConfig(io.Local)
@ -62,16 +60,16 @@ images:
}) })
} }
func TestLoadConfig_Bad(t *testing.T) { func TestConfig_LoadConfig_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 := filepath.Join(tempHome, ".core") coreDir := coreutil.JoinPath(tempHome, ".core")
err := os.MkdirAll(coreDir, 0755) err := io.Local.EnsureDir(coreDir)
require.NoError(t, err) require.NoError(t, err)
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte("invalid: yaml: :"), 0644) err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), "invalid: yaml: :")
require.NoError(t, err) require.NoError(t, err)
_, err = LoadConfig(io.Local) _, err = LoadConfig(io.Local)
@ -79,7 +77,7 @@ func TestLoadConfig_Bad(t *testing.T) {
}) })
} }
func TestConfig_Struct(t *testing.T) { func TestConfig_Struct_Good(t *testing.T) {
cfg := &Config{ cfg := &Config{
Version: 2, Version: 2,
Images: ImagesConfig{ Images: ImagesConfig{
@ -102,7 +100,7 @@ func TestConfig_Struct(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(t *testing.T) { func TestDefaultConfig_Complete_Good(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)
@ -111,12 +109,12 @@ func TestDefaultConfig_Complete(t *testing.T) {
assert.Empty(t, cfg.Images.CDN.URL) assert.Empty(t, cfg.Images.CDN.URL)
} }
func TestLoadConfig_Good_PartialConfig(t *testing.T) { func TestLoadConfig_PartialConfig_Good(t *testing.T) {
tempHome := t.TempDir() tempHome := t.TempDir()
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)
coreDir := filepath.Join(tempHome, ".core") coreDir := coreutil.JoinPath(tempHome, ".core")
err := os.MkdirAll(coreDir, 0755) err := io.Local.EnsureDir(coreDir)
require.NoError(t, err) require.NoError(t, err)
// Config only specifies source, should merge with defaults // Config only specifies source, should merge with defaults
@ -125,7 +123,7 @@ version: 1
images: images:
source: github source: github
` `
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), configData)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig(io.Local) cfg, err := LoadConfig(io.Local)
@ -136,7 +134,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_Good_AllSourceTypes(t *testing.T) { func TestLoadConfig_AllSourceTypes_Good(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
config string config string
@ -191,11 +189,11 @@ images:
tempHome := t.TempDir() tempHome := t.TempDir()
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)
coreDir := filepath.Join(tempHome, ".core") coreDir := coreutil.JoinPath(tempHome, ".core")
err := os.MkdirAll(coreDir, 0755) err := io.Local.EnsureDir(coreDir)
require.NoError(t, err) require.NoError(t, err)
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(tt.config), 0644) err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), tt.config)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig(io.Local) cfg, err := LoadConfig(io.Local)
@ -205,7 +203,7 @@ images:
} }
} }
func TestImagesConfig_Struct(t *testing.T) { func TestImagesConfig_Struct_Good(t *testing.T) {
ic := ImagesConfig{ ic := ImagesConfig{
Source: "auto", Source: "auto",
GitHub: GitHubConfig{Repo: "test/repo"}, GitHub: GitHubConfig{Repo: "test/repo"},
@ -214,42 +212,42 @@ func TestImagesConfig_Struct(t *testing.T) {
assert.Equal(t, "test/repo", ic.GitHub.Repo) assert.Equal(t, "test/repo", ic.GitHub.Repo)
} }
func TestGitHubConfig_Struct(t *testing.T) { func TestGitHubConfig_Struct_Good(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(t *testing.T) { func TestRegistryConfig_Struct_Good(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(t *testing.T) { func TestCDNConfig_Struct_Good(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_Bad_UnreadableFile(t *testing.T) { func TestLoadConfig_UnreadableFile_Bad(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 os.Getuid() == 0 { if syscall.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 := filepath.Join(tempHome, ".core") coreDir := coreutil.JoinPath(tempHome, ".core")
err := os.MkdirAll(coreDir, 0755) err := io.Local.EnsureDir(coreDir)
require.NoError(t, err) require.NoError(t, err)
configPath := filepath.Join(coreDir, "config.yaml") configPath := coreutil.JoinPath(coreDir, "config.yaml")
err = os.WriteFile(configPath, []byte("version: 1"), 0000) err = io.Local.WriteMode(configPath, "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
_ = os.Chmod(configPath, 0644) _ = syscall.Chmod(configPath, 0644)
} }

View file

@ -3,15 +3,15 @@ package devenv
import ( import (
"context" "context"
"fmt"
"os"
"path/filepath"
"runtime" "runtime"
"time" "time"
"forge.lthn.ai/core/go-container" core "dappco.re/go/core"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/container"
coreerr "forge.lthn.ai/core/go-log" "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/coreutil"
) )
const ( const (
@ -28,6 +28,10 @@ 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 {
@ -53,29 +57,41 @@ 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 fmt.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH) return core.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 := os.Getenv("CORE_IMAGES_DIR"); dir != "" { if dir := core.Env("CORE_IMAGES_DIR"); dir != "" {
return dir, nil return dir, nil
} }
home, err := os.UserHomeDir() home := coreutil.HomeDir()
if err != nil { if home == "" {
return "", err return "", core.E("ImagesDir", "home directory not available", nil)
} }
return filepath.Join(home, ".core", "images"), nil return coreutil.JoinPath(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 filepath.Join(dir, ImageName()), nil return coreutil.JoinPath(dir, ImageName()), nil
} }
// IsInstalled checks if the dev image is installed. // IsInstalled checks if the dev image is installed.
@ -106,6 +122,10 @@ 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,

View file

@ -2,20 +2,29 @@ package devenv
import ( import (
"context" "context"
"os"
"os/exec"
"path/filepath"
"runtime" "runtime"
"syscall"
"testing" "testing"
"time" "time"
"forge.lthn.ai/core/go-container" core "dappco.re/go/core"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/container"
"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 TestImageName(t *testing.T) { func newManagedTempDir(t *testing.T, prefix string) string {
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)
@ -23,12 +32,9 @@ func TestImageName(t *testing.T) {
assert.True(t, (name[len(name)-6:] == ".qcow2")) assert.True(t, (name[len(name)-6:] == ".qcow2"))
} }
func TestImagesDir(t *testing.T) { func TestDevOps_ImagesDir_Good(t *testing.T) {
t.Run("default directory", func(t *testing.T) { t.Run("default directory", func(t *testing.T) {
// Unset env if it exists t.Setenv("CORE_IMAGES_DIR", "")
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)
@ -45,17 +51,17 @@ func TestImagesDir(t *testing.T) {
}) })
} }
func TestImagePath(t *testing.T) { func TestDevOps_ImagePath_Good(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 := filepath.Join(customDir, ImageName()) expected := coreutil.JoinPath(customDir, ImageName())
assert.Equal(t, expected, path) assert.Equal(t, expected, path)
} }
func TestDefaultBootOptions(t *testing.T) { func TestDevOps_DefaultBootOptions_Good(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)
@ -63,7 +69,7 @@ func TestDefaultBootOptions(t *testing.T) {
assert.False(t, opts.Fresh) assert.False(t, opts.Fresh)
} }
func TestIsInstalled_Bad(t *testing.T) { func TestDevOps_IsInstalled_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()
@ -75,14 +81,14 @@ func TestIsInstalled_Bad(t *testing.T) {
}) })
} }
func TestIsInstalled_Good(t *testing.T) { func TestDevOps_IsInstalled_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 := filepath.Join(tempDir, ImageName()) imagePath := coreutil.JoinPath(tempDir, ImageName())
err := os.WriteFile(imagePath, []byte("fake image data"), 0644) err := io.Local.Write(imagePath, "fake image data")
require.NoError(t, err) require.NoError(t, err)
d := &DevOps{medium: io.Local} d := &DevOps{medium: io.Local}
@ -94,8 +100,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) (*exec.Cmd, error) { func (m *mockHypervisor) BuildCommand(ctx context.Context, image string, opts *container.HypervisorOptions) (*proc.Command, error) {
return exec.Command("true"), nil return proc.NewCommand("true"), nil
} }
func TestDevOps_Status_Good(t *testing.T) { func TestDevOps_Status_Good(t *testing.T) {
@ -107,7 +113,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 := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -122,7 +128,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: os.Getpid(), // Use our own PID so isProcessRunning returns true PID: syscall.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,
@ -139,7 +145,7 @@ func TestDevOps_Status_Good(t *testing.T) {
assert.Equal(t, 4, status.CPUs) assert.Equal(t, 4, status.CPUs)
} }
func TestDevOps_Status_Good_NotInstalled(t *testing.T) { func TestDevOps_Status_NotInstalled_Good(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
@ -147,7 +153,7 @@ func TestDevOps_Status_Good_NotInstalled(t *testing.T) {
mgr, err := NewImageManager(io.Local, cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -165,20 +171,20 @@ func TestDevOps_Status_Good_NotInstalled(t *testing.T) {
assert.Equal(t, 2222, status.SSHPort) assert.Equal(t, 2222, status.SSHPort)
} }
func TestDevOps_Status_Good_NoContainer(t *testing.T) { func TestDevOps_Status_NoContainer_Good(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 := filepath.Join(tempDir, ImageName()) imagePath := coreutil.JoinPath(tempDir, ImageName())
err := os.WriteFile(imagePath, []byte("fake"), 0644) err := io.Local.Write(imagePath, "fake")
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 := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -204,7 +210,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 := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -218,7 +224,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: os.Getpid(), PID: syscall.Getpid(),
StartedAt: time.Now(), StartedAt: time.Now(),
} }
err = state.Add(c) err = state.Add(c)
@ -229,7 +235,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) {
assert.True(t, running) assert.True(t, running)
} }
func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) { func TestDevOps_IsRunning_NotRunning_Bad(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
@ -237,7 +243,7 @@ func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) {
mgr, err := NewImageManager(io.Local, cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -252,7 +258,7 @@ func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) {
assert.False(t, running) assert.False(t, running)
} }
func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) { func TestDevOps_IsRunning_ContainerStopped_Bad(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
@ -260,7 +266,7 @@ func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) {
mgr, err := NewImageManager(io.Local, cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -293,7 +299,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 := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -307,7 +313,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: os.Getpid(), PID: syscall.Getpid(),
StartedAt: time.Now(), StartedAt: time.Now(),
} }
err = state.Add(c) err = state.Add(c)
@ -320,7 +326,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_Bad_NotFound(t *testing.T) { func TestDevOps_findContainer_NotFound_Bad(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
@ -328,7 +334,7 @@ func TestDevOps_findContainer_Bad_NotFound(t *testing.T) {
mgr, err := NewImageManager(io.Local, cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -343,7 +349,7 @@ func TestDevOps_findContainer_Bad_NotFound(t *testing.T) {
assert.Nil(t, found) assert.Nil(t, found)
} }
func TestDevOps_Stop_Bad_NotFound(t *testing.T) { func TestDevOps_Stop_NotFound_Bad(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
@ -351,7 +357,7 @@ func TestDevOps_Stop_Bad_NotFound(t *testing.T) {
mgr, err := NewImageManager(io.Local, cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -366,7 +372,7 @@ func TestDevOps_Stop_Bad_NotFound(t *testing.T) {
assert.Contains(t, err.Error(), "not found") assert.Contains(t, err.Error(), "not found")
} }
func TestBootOptions_Custom(t *testing.T) { func TestBootOptions_Custom_Good(t *testing.T) {
opts := BootOptions{ opts := BootOptions{
Memory: 8192, Memory: 8192,
CPUs: 4, CPUs: 4,
@ -379,7 +385,7 @@ func TestBootOptions_Custom(t *testing.T) {
assert.True(t, opts.Fresh) assert.True(t, opts.Fresh)
} }
func TestDevStatus_Struct(t *testing.T) { func TestDevStatus_Struct_Good(t *testing.T) {
status := DevStatus{ status := DevStatus{
Installed: true, Installed: true,
Running: true, Running: true,
@ -400,7 +406,7 @@ func TestDevStatus_Struct(t *testing.T) {
assert.Equal(t, time.Hour, status.Uptime) assert.Equal(t, time.Hour, status.Uptime)
} }
func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) { func TestDevOps_Boot_NotInstalled_Bad(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
@ -408,7 +414,7 @@ func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) {
mgr, err := NewImageManager(io.Local, cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -423,20 +429,20 @@ func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) {
assert.Contains(t, err.Error(), "not installed") assert.Contains(t, err.Error(), "not installed")
} }
func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) { func TestDevOps_Boot_AlreadyRunning_Bad(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 := filepath.Join(tempDir, ImageName()) imagePath := coreutil.JoinPath(tempDir, ImageName())
err := os.WriteFile(imagePath, []byte("fake"), 0644) err := io.Local.Write(imagePath, "fake")
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 := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -451,7 +457,7 @@ func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) {
ID: "test-id", ID: "test-id",
Name: "core-dev", Name: "core-dev",
Status: container.StatusRunning, Status: container.StatusRunning,
PID: os.Getpid(), PID: syscall.Getpid(),
StartedAt: time.Now(), StartedAt: time.Now(),
} }
err = state.Add(c) err = state.Add(c)
@ -462,13 +468,13 @@ func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) {
assert.Contains(t, err.Error(), "already running") assert.Contains(t, err.Error(), "already running")
} }
func TestDevOps_Status_Good_WithImageVersion(t *testing.T) { func TestDevOps_Status_WithImageVersion_Good(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 := filepath.Join(tempDir, ImageName()) imagePath := coreutil.JoinPath(tempDir, ImageName())
err := os.WriteFile(imagePath, []byte("fake"), 0644) err := io.Local.Write(imagePath, "fake")
require.NoError(t, err) require.NoError(t, err)
cfg := DefaultConfig() cfg := DefaultConfig()
@ -481,7 +487,7 @@ func TestDevOps_Status_Good_WithImageVersion(t *testing.T) {
Source: "test", Source: "test",
} }
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -498,7 +504,7 @@ func TestDevOps_Status_Good_WithImageVersion(t *testing.T) {
assert.Equal(t, "v1.2.3", status.ImageVersion) assert.Equal(t, "v1.2.3", status.ImageVersion)
} }
func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) { func TestDevOps_findContainer_MultipleContainers_Good(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
@ -506,7 +512,7 @@ func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) {
mgr, err := NewImageManager(io.Local, cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -521,14 +527,14 @@ func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) {
ID: "id-1", ID: "id-1",
Name: "container-1", Name: "container-1",
Status: container.StatusRunning, Status: container.StatusRunning,
PID: os.Getpid(), PID: syscall.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: os.Getpid(), PID: syscall.Getpid(),
StartedAt: time.Now(), StartedAt: time.Now(),
} }
err = state.Add(c1) err = state.Add(c1)
@ -543,7 +549,7 @@ func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) {
assert.Equal(t, "id-2", found.ID) assert.Equal(t, "id-2", found.ID)
} }
func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) { func TestDevOps_Status_ContainerWithUptime_Good(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
@ -551,7 +557,7 @@ func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) {
mgr, err := NewImageManager(io.Local, cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -566,7 +572,7 @@ func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) {
ID: "test-id", ID: "test-id",
Name: "core-dev", Name: "core-dev",
Status: container.StatusRunning, Status: container.StatusRunning,
PID: os.Getpid(), PID: syscall.Getpid(),
StartedAt: startTime, StartedAt: startTime,
Memory: 4096, Memory: 4096,
CPUs: 2, CPUs: 2,
@ -580,7 +586,7 @@ func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) {
assert.GreaterOrEqual(t, status.Uptime.Hours(), float64(1)) assert.GreaterOrEqual(t, status.Uptime.Hours(), float64(1))
} }
func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) { func TestDevOps_IsRunning_DifferentContainerName_Bad(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
@ -588,7 +594,7 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) {
mgr, err := NewImageManager(io.Local, cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -603,7 +609,7 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) {
ID: "test-id", ID: "test-id",
Name: "other-container", Name: "other-container",
Status: container.StatusRunning, Status: container.StatusRunning,
PID: os.Getpid(), PID: syscall.Getpid(),
StartedAt: time.Now(), StartedAt: time.Now(),
} }
err = state.Add(c) err = state.Add(c)
@ -615,23 +621,21 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) {
assert.False(t, running) assert.False(t, running)
} }
func TestDevOps_Boot_Good_FreshFlag(t *testing.T) { func TestDevOps_Boot_FreshFlag_Good(t *testing.T) {
t.Setenv("CORE_SKIP_SSH_SCAN", "true") t.Setenv("CORE_SKIP_SSH_SCAN", "true")
tempDir, err := os.MkdirTemp("", "devops-test-*") tempDir := newManagedTempDir(t, "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 := filepath.Join(tempDir, ImageName()) imagePath := coreutil.JoinPath(tempDir, ImageName())
err = os.WriteFile(imagePath, []byte("fake"), 0644) err := io.Local.Write(imagePath, "fake")
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 := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -665,7 +669,7 @@ func TestDevOps_Boot_Good_FreshFlag(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) { func TestDevOps_Stop_ContainerNotRunning_Bad(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
@ -673,7 +677,7 @@ func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) {
mgr, err := NewImageManager(io.Local, cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -700,23 +704,21 @@ func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) {
assert.Contains(t, err.Error(), "not running") assert.Contains(t, err.Error(), "not running")
} }
func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) { func TestDevOps_Boot_FreshWithNoExisting_Good(t *testing.T) {
t.Setenv("CORE_SKIP_SSH_SCAN", "true") t.Setenv("CORE_SKIP_SSH_SCAN", "true")
tempDir, err := os.MkdirTemp("", "devops-boot-fresh-*") tempDir := newManagedTempDir(t, "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 := filepath.Join(tempDir, ImageName()) imagePath := coreutil.JoinPath(tempDir, ImageName())
err = os.WriteFile(imagePath, []byte("fake"), 0644) err := io.Local.Write(imagePath, "fake")
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 := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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)
@ -738,16 +740,16 @@ func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestImageName_Format(t *testing.T) { func TestImageName_Format_Good(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, filepath.Ext(name) == ".qcow2") assert.True(t, core.PathExt(name) == ".qcow2")
} }
func TestDevOps_Install_Delegates(t *testing.T) { func TestDevOps_Install_Delegates_Good(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)
@ -765,7 +767,7 @@ func TestDevOps_Install_Delegates(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
} }
func TestDevOps_CheckUpdate_Delegates(t *testing.T) { func TestDevOps_CheckUpdate_Delegates_Good(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)
@ -783,23 +785,21 @@ func TestDevOps_CheckUpdate_Delegates(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
} }
func TestDevOps_Boot_Good_Success(t *testing.T) { func TestDevOps_Boot_Success_Good(t *testing.T) {
t.Setenv("CORE_SKIP_SSH_SCAN", "true") t.Setenv("CORE_SKIP_SSH_SCAN", "true")
tempDir, err := os.MkdirTemp("", "devops-boot-success-*") tempDir := newManagedTempDir(t, "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 := filepath.Join(tempDir, ImageName()) imagePath := coreutil.JoinPath(tempDir, ImageName())
err = os.WriteFile(imagePath, []byte("fake"), 0644) err := io.Local.Write(imagePath, "fake")
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 := filepath.Join(tempDir, "containers.json") statePath := coreutil.JoinPath(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_Good_Success(t *testing.T) {
assert.NoError(t, err) // Mock hypervisor succeeds assert.NoError(t, err) // Mock hypervisor succeeds
} }
func TestDevOps_Config(t *testing.T) { func TestDevOps_Config_Good(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)

View file

@ -2,15 +2,15 @@ package devenv
import ( import (
"context" "context"
"encoding/json" "io/fs"
"fmt"
"os"
"path/filepath"
"time" "time"
"forge.lthn.ai/core/go-container/sources" core "dappco.re/go/core"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/container/sources"
coreerr "forge.lthn.ai/core/go-log" "dappco.re/go/core/io"
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,6 +37,10 @@ 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 {
@ -49,7 +53,7 @@ func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) {
} }
// Load or create manifest // Load or create manifest
manifestPath := filepath.Join(imagesDir, "manifest.json") manifestPath := coreutil.JoinPath(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
@ -119,7 +123,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)
} }
fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name()) core.Print(nil, "Downloading %s from %s...", 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 {
@ -174,14 +178,15 @@ 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 os.IsNotExist(err) { if core.Is(err, fs.ErrNotExist) {
return manifest, nil return manifest, nil
} }
return nil, err return nil, err
} }
if err := json.Unmarshal([]byte(content), manifest); err != nil { result := core.JSONUnmarshalString(content, manifest)
return nil, err if !result.OK {
return nil, result.Value.(error)
} }
manifest.medium = m manifest.medium = m
manifest.path = path manifest.path = path
@ -191,9 +196,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 {
data, err := json.MarshalIndent(m, "", " ") result := core.JSONMarshal(m)
if err != nil { if !result.OK {
return err return result.Value.(error)
} }
return m.medium.Write(m.path, string(data)) return m.medium.Write(m.path, string(result.Value.([]byte)))
} }

View file

@ -2,18 +2,17 @@ package devenv
import ( import (
"context" "context"
"os"
"path/filepath"
"testing" "testing"
"time" "time"
"forge.lthn.ai/core/go-container/sources" "dappco.re/go/core/container/internal/coreutil"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/container/sources"
"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_Good_IsInstalled(t *testing.T) { func TestImageManager_IsInstalled_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -25,15 +24,15 @@ func TestImageManager_Good_IsInstalled(t *testing.T) {
assert.False(t, mgr.IsInstalled()) assert.False(t, mgr.IsInstalled())
// Create fake image // Create fake image
imagePath := filepath.Join(tmpDir, ImageName()) imagePath := coreutil.JoinPath(tmpDir, ImageName())
err = os.WriteFile(imagePath, []byte("fake"), 0644) err = io.Local.Write(imagePath, "fake")
require.NoError(t, err) require.NoError(t, err)
// Now installed // Now installed
assert.True(t, mgr.IsInstalled()) assert.True(t, mgr.IsInstalled())
} }
func TestNewImageManager_Good(t *testing.T) { func TestImages_NewImageManager_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)
@ -63,9 +62,9 @@ func TestNewImageManager_Good(t *testing.T) {
}) })
} }
func TestManifest_Save(t *testing.T) { func TestManifest_Save_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "manifest.json") path := coreutil.JoinPath(tmpDir, "manifest.json")
m := &Manifest{ m := &Manifest{
medium: io.Local, medium: io.Local,
@ -82,8 +81,7 @@ func TestManifest_Save(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// Verify file exists and has content // Verify file exists and has content
_, err = os.Stat(path) assert.True(t, io.Local.IsFile(path))
assert.NoError(t, err)
// Reload // Reload
m2, err := loadManifest(io.Local, path) m2, err := loadManifest(io.Local, path)
@ -91,11 +89,11 @@ func TestManifest_Save(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 TestLoadManifest_Bad(t *testing.T) { func TestImages_LoadManifest_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 := filepath.Join(tmpDir, "manifest.json") path := coreutil.JoinPath(tmpDir, "manifest.json")
err := os.WriteFile(path, []byte("invalid json"), 0644) err := io.Local.Write(path, "invalid json")
require.NoError(t, err) require.NoError(t, err)
_, err = loadManifest(io.Local, path) _, err = loadManifest(io.Local, path)
@ -103,7 +101,7 @@ func TestLoadManifest_Bad(t *testing.T) {
}) })
} }
func TestCheckUpdate_Bad(t *testing.T) { func TestImages_CheckUpdate_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)
@ -118,7 +116,7 @@ func TestCheckUpdate_Bad(t *testing.T) {
}) })
} }
func TestNewImageManager_Good_AutoSource(t *testing.T) { func TestNewImageManager_AutoSource_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -131,7 +129,7 @@ func TestNewImageManager_Good_AutoSource(t *testing.T) {
assert.Len(t, mgr.sources, 2) // github and cdn assert.Len(t, mgr.sources, 2) // github and cdn
} }
func TestNewImageManager_Good_UnknownSourceFallsToAuto(t *testing.T) { func TestNewImageManager_UnknownSourceFallsToAuto_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -144,9 +142,9 @@ func TestNewImageManager_Good_UnknownSourceFallsToAuto(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_Good_Empty(t *testing.T) { func TestLoadManifest_Empty_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "nonexistent.json") path := coreutil.JoinPath(tmpDir, "nonexistent.json")
m, err := loadManifest(io.Local, path) m, err := loadManifest(io.Local, path)
assert.NoError(t, err) assert.NoError(t, err)
@ -156,12 +154,12 @@ func TestLoadManifest_Good_Empty(t *testing.T) {
assert.Equal(t, path, m.path) assert.Equal(t, path, m.path)
} }
func TestLoadManifest_Good_ExistingData(t *testing.T) { func TestLoadManifest_ExistingData_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "manifest.json") path := coreutil.JoinPath(tmpDir, "manifest.json")
data := `{"images":{"test.img":{"version":"2.0.0","source":"cdn"}}}` data := `{"images":{"test.img":{"version":"2.0.0","source":"cdn"}}}`
err := os.WriteFile(path, []byte(data), 0644) err := io.Local.Write(path, data)
require.NoError(t, err) require.NoError(t, err)
m, err := loadManifest(io.Local, path) m, err := loadManifest(io.Local, path)
@ -171,7 +169,7 @@ func TestLoadManifest_Good_ExistingData(t *testing.T) {
assert.Equal(t, "cdn", m.Images["test.img"].Source) assert.Equal(t, "cdn", m.Images["test.img"].Source)
} }
func TestImageInfo_Struct(t *testing.T) { func TestImageInfo_Struct_Good(t *testing.T) {
info := ImageInfo{ info := ImageInfo{
Version: "1.0.0", Version: "1.0.0",
SHA256: "abc123", SHA256: "abc123",
@ -184,9 +182,9 @@ func TestImageInfo_Struct(t *testing.T) {
assert.Equal(t, "github", info.Source) assert.Equal(t, "github", info.Source)
} }
func TestManifest_Save_Good_CreatesDirs(t *testing.T) { func TestManifest_Save_CreatesDirs_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
nestedPath := filepath.Join(tmpDir, "nested", "dir", "manifest.json") nestedPath := coreutil.JoinPath(tmpDir, "nested", "dir", "manifest.json")
m := &Manifest{ m := &Manifest{
medium: io.Local, medium: io.Local,
@ -200,13 +198,12 @@ func TestManifest_Save_Good_CreatesDirs(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// Verify file was created // Verify file was created
_, err = os.Stat(nestedPath) assert.True(t, io.Local.IsFile(nestedPath))
assert.NoError(t, err)
} }
func TestManifest_Save_Good_Overwrite(t *testing.T) { func TestManifest_Save_Overwrite_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "manifest.json") path := coreutil.JoinPath(tmpDir, "manifest.json")
// First save // First save
m1 := &Manifest{ m1 := &Manifest{
@ -236,7 +233,7 @@ func TestManifest_Save_Good_Overwrite(t *testing.T) {
assert.False(t, exists) assert.False(t, exists)
} }
func TestImageManager_Install_Bad_NoSourceAvailable(t *testing.T) { func TestImageManager_Install_NoSourceAvailable_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -244,7 +241,7 @@ func TestImageManager_Install_Bad_NoSourceAvailable(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: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
sources: nil, // no sources sources: nil, // no sources
} }
@ -253,9 +250,9 @@ func TestImageManager_Install_Bad_NoSourceAvailable(t *testing.T) {
assert.Contains(t, err.Error(), "no image source available") assert.Contains(t, err.Error(), "no image source available")
} }
func TestNewImageManager_Good_CreatesDir(t *testing.T) { func TestNewImageManager_CreatesDir_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
imagesDir := filepath.Join(tmpDir, "images") imagesDir := coreutil.JoinPath(tmpDir, "images")
t.Setenv("CORE_IMAGES_DIR", imagesDir) t.Setenv("CORE_IMAGES_DIR", imagesDir)
cfg := DefaultConfig() cfg := DefaultConfig()
@ -264,7 +261,7 @@ func TestNewImageManager_Good_CreatesDir(t *testing.T) {
assert.NotNil(t, mgr) assert.NotNil(t, mgr)
// Verify directory was created // Verify directory was created
info, err := os.Stat(imagesDir) info, err := io.Local.Stat(imagesDir)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, info.IsDir()) assert.True(t, info.IsDir())
} }
@ -288,11 +285,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 := filepath.Join(dest, ImageName()) imagePath := coreutil.JoinPath(dest, ImageName())
return os.WriteFile(imagePath, []byte("mock image content"), 0644) return medium.Write(imagePath, "mock image content")
} }
func TestImageManager_Install_Good_WithMockSource(t *testing.T) { func TestImageManager_Install_WithMockSource_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -305,7 +302,7 @@ func TestImageManager_Install_Good_WithMockSource(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: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
sources: []sources.ImageSource{mock}, sources: []sources.ImageSource{mock},
} }
@ -320,7 +317,7 @@ func TestImageManager_Install_Good_WithMockSource(t *testing.T) {
assert.Equal(t, "mock", info.Source) assert.Equal(t, "mock", info.Source)
} }
func TestImageManager_Install_Bad_DownloadError(t *testing.T) { func TestImageManager_Install_DownloadError_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -334,7 +331,7 @@ func TestImageManager_Install_Bad_DownloadError(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: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
sources: []sources.ImageSource{mock}, sources: []sources.ImageSource{mock},
} }
@ -342,7 +339,7 @@ func TestImageManager_Install_Bad_DownloadError(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
} }
func TestImageManager_Install_Bad_VersionError(t *testing.T) { func TestImageManager_Install_VersionError_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -355,7 +352,7 @@ func TestImageManager_Install_Bad_VersionError(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: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
sources: []sources.ImageSource{mock}, sources: []sources.ImageSource{mock},
} }
@ -364,7 +361,7 @@ func TestImageManager_Install_Bad_VersionError(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_Good_SkipsUnavailableSource(t *testing.T) { func TestImageManager_Install_SkipsUnavailableSource_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -381,7 +378,7 @@ func TestImageManager_Install_Good_SkipsUnavailableSource(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: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
sources: []sources.ImageSource{unavailableMock, availableMock}, sources: []sources.ImageSource{unavailableMock, availableMock},
} }
@ -393,7 +390,7 @@ func TestImageManager_Install_Good_SkipsUnavailableSource(t *testing.T) {
assert.Equal(t, "available", info.Source) assert.Equal(t, "available", info.Source)
} }
func TestImageManager_CheckUpdate_Good_WithMockSource(t *testing.T) { func TestImageManager_CheckUpdate_WithMockSource_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -411,7 +408,7 @@ func TestImageManager_CheckUpdate_Good_WithMockSource(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: filepath.Join(tmpDir, "manifest.json"), path: coreutil.JoinPath(tmpDir, "manifest.json"),
}, },
sources: []sources.ImageSource{mock}, sources: []sources.ImageSource{mock},
} }
@ -423,7 +420,7 @@ func TestImageManager_CheckUpdate_Good_WithMockSource(t *testing.T) {
assert.True(t, hasUpdate) assert.True(t, hasUpdate)
} }
func TestImageManager_CheckUpdate_Good_NoUpdate(t *testing.T) { func TestImageManager_CheckUpdate_NoUpdate_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -441,7 +438,7 @@ func TestImageManager_CheckUpdate_Good_NoUpdate(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: filepath.Join(tmpDir, "manifest.json"), path: coreutil.JoinPath(tmpDir, "manifest.json"),
}, },
sources: []sources.ImageSource{mock}, sources: []sources.ImageSource{mock},
} }
@ -453,7 +450,7 @@ func TestImageManager_CheckUpdate_Good_NoUpdate(t *testing.T) {
assert.False(t, hasUpdate) assert.False(t, hasUpdate)
} }
func TestImageManager_CheckUpdate_Bad_NoSource(t *testing.T) { func TestImageManager_CheckUpdate_NoSource_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -470,7 +467,7 @@ func TestImageManager_CheckUpdate_Bad_NoSource(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: filepath.Join(tmpDir, "manifest.json"), path: coreutil.JoinPath(tmpDir, "manifest.json"),
}, },
sources: []sources.ImageSource{unavailableMock}, sources: []sources.ImageSource{unavailableMock},
} }
@ -480,7 +477,7 @@ func TestImageManager_CheckUpdate_Bad_NoSource(t *testing.T) {
assert.Contains(t, err.Error(), "no image source available") assert.Contains(t, err.Error(), "no image source available")
} }
func TestImageManager_CheckUpdate_Bad_VersionError(t *testing.T) { func TestImageManager_CheckUpdate_VersionError_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -498,7 +495,7 @@ func TestImageManager_CheckUpdate_Bad_VersionError(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: filepath.Join(tmpDir, "manifest.json"), path: coreutil.JoinPath(tmpDir, "manifest.json"),
}, },
sources: []sources.ImageSource{mock}, sources: []sources.ImageSource{mock},
} }
@ -508,14 +505,14 @@ func TestImageManager_CheckUpdate_Bad_VersionError(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_Bad_EmptySources(t *testing.T) { func TestImageManager_Install_EmptySources_Bad(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: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
sources: []sources.ImageSource{}, // Empty slice, not nil sources: []sources.ImageSource{}, // Empty slice, not nil
} }
@ -524,7 +521,7 @@ func TestImageManager_Install_Bad_EmptySources(t *testing.T) {
assert.Contains(t, err.Error(), "no image source available") assert.Contains(t, err.Error(), "no image source available")
} }
func TestImageManager_Install_Bad_AllUnavailable(t *testing.T) { func TestImageManager_Install_AllUnavailable_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -534,7 +531,7 @@ func TestImageManager_Install_Bad_AllUnavailable(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: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")},
sources: []sources.ImageSource{mock1, mock2}, sources: []sources.ImageSource{mock1, mock2},
} }
@ -543,7 +540,7 @@ func TestImageManager_Install_Bad_AllUnavailable(t *testing.T) {
assert.Contains(t, err.Error(), "no image source available") assert.Contains(t, err.Error(), "no image source available")
} }
func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(t *testing.T) { func TestImageManager_CheckUpdate_FirstSourceUnavailable_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
@ -558,7 +555,7 @@ func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(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: filepath.Join(tmpDir, "manifest.json"), path: coreutil.JoinPath(tmpDir, "manifest.json"),
}, },
sources: []sources.ImageSource{unavailable, available}, sources: []sources.ImageSource{unavailable, available},
} }
@ -570,7 +567,7 @@ func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(t *testing.T) {
assert.True(t, hasUpdate) assert.True(t, hasUpdate)
} }
func TestManifest_Struct(t *testing.T) { func TestManifest_Struct_Good(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"},

View file

@ -2,13 +2,13 @@ package devenv
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"path/filepath"
"forge.lthn.ai/core/go-io" core "dappco.re/go/core"
coreerr "forge.lthn.ai/core/go-log" "dappco.re/go/core/io"
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 = filepath.Join(projectDir, opts.Path) servePath = coreutil.JoinPath(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)
fmt.Printf("Starting server: %s\n", serveCmd) core.Print(nil, "Starting server: %s", serveCmd)
fmt.Printf("Listening on http://localhost:%d\n", opts.Port) core.Print(nil, "Listening on http://localhost:%d", 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,26 +52,27 @@ 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, err := filepath.Abs(path) absPath := coreutil.AbsPath(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 := exec.CommandContext(ctx, "ssh", cmd := proc.NewCommandContext(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", fmt.Sprintf("%d", DefaultSSHPort), "-p", core.Sprintf("%d", DefaultSSHPort),
"root@localhost", "root@localhost",
fmt.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", os.Getenv("USER"), absPath), core.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", core.Env("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") {

View file

@ -1,66 +1,65 @@
package devenv package devenv
import ( import (
"os"
"path/filepath"
"testing" "testing"
"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 TestDetectServeCommand_Good_Laravel(t *testing.T) { func TestDetectServeCommand_Laravel_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "artisan"), "#!/usr/bin/env php")
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_Good_NodeDev(t *testing.T) { func TestDetectServeCommand_NodeDev_Good(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 := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), packageJSON)
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_Good_NodeStart(t *testing.T) { func TestDetectServeCommand_NodeStart_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
packageJSON := `{"scripts":{"start":"node server.js"}}` packageJSON := `{"scripts":{"start":"node server.js"}}`
err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), packageJSON)
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_Good_PHP(t *testing.T) { func TestDetectServeCommand_PHP_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"require":{}}`)
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_Good_GoMain(t *testing.T) { func TestDetectServeCommand_GoMain_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example")
assert.NoError(t, err) assert.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644) err = io.Local.Write(coreutil.JoinPath(tmpDir, "main.go"), "package main")
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_Good_GoWithoutMain(t *testing.T) { func TestDetectServeCommand_GoWithoutMain_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example")
assert.NoError(t, err) assert.NoError(t, err)
// No main.go, so falls through to fallback // No main.go, so falls through to fallback
@ -68,41 +67,41 @@ func TestDetectServeCommand_Good_GoWithoutMain(t *testing.T) {
assert.Equal(t, "python3 -m http.server 8000", cmd) assert.Equal(t, "python3 -m http.server 8000", cmd)
} }
func TestDetectServeCommand_Good_Django(t *testing.T) { func TestDetectServeCommand_Django_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := os.WriteFile(filepath.Join(tmpDir, "manage.py"), []byte("#!/usr/bin/env python"), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "manage.py"), "#!/usr/bin/env python")
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_Good_Fallback(t *testing.T) { func TestDetectServeCommand_Fallback_Good(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_Good_Priority(t *testing.T) { func TestDetectServeCommand_Priority_Good(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 := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "artisan"), "#!/usr/bin/env php")
assert.NoError(t, err) assert.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) err = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"require":{}}`)
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(t *testing.T) { func TestServeOptions_Default_Good(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(t *testing.T) { func TestServeOptions_Custom_Good(t *testing.T) {
opts := ServeOptions{ opts := ServeOptions{
Port: 3000, Port: 3000,
Path: "public", Path: "public",
@ -111,25 +110,25 @@ func TestServeOptions_Custom(t *testing.T) {
assert.Equal(t, "public", opts.Path) assert.Equal(t, "public", opts.Path)
} }
func TestHasFile_Good(t *testing.T) { func TestServe_HasFile_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt") testFile := coreutil.JoinPath(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("content"), 0644) err := io.Local.Write(testFile, "content")
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 TestHasFile_Bad(t *testing.T) { func TestServe_HasFile_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_Bad_Directory(t *testing.T) { func TestHasFile_Directory_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, "subdir") subDir := coreutil.JoinPath(tmpDir, "subdir")
err := os.Mkdir(subDir, 0755) err := io.Local.EnsureDir(subDir)
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)

View file

@ -2,11 +2,11 @@ package devenv
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
coreerr "forge.lthn.ai/core/go-log" core "dappco.re/go/core"
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", fmt.Sprintf("%d", DefaultSSHPort), "-p", core.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 := exec.CommandContext(ctx, "ssh", args...) cmd := proc.NewCommandContext(ctx, "ssh", args...)
cmd.Stdin = os.Stdin cmd.Stdin = proc.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = proc.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = proc.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 := fmt.Sprintf("/tmp/core-%s-console.sock", c.ID) socketPath := core.Sprintf("/tmp/core-%s-console.sock", c.ID)
cmd := exec.CommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath) cmd := proc.NewCommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath)
cmd.Stdin = os.Stdin cmd.Stdin = proc.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = proc.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = proc.Stderr
return cmd.Run() return cmd.Run()
} }

View file

@ -6,13 +6,13 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestShellOptions_Default(t *testing.T) { func TestShellOptions_Default_Good(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(t *testing.T) { func TestShellOptions_Console_Good(t *testing.T) {
opts := ShellOptions{ opts := ShellOptions{
Console: true, Console: true,
} }
@ -20,7 +20,7 @@ func TestShellOptions_Console(t *testing.T) {
assert.Nil(t, opts.Command) assert.Nil(t, opts.Command)
} }
func TestShellOptions_Command(t *testing.T) { func TestShellOptions_Command_Good(t *testing.T) {
opts := ShellOptions{ opts := ShellOptions{
Command: []string{"ls", "-la"}, Command: []string{"ls", "-la"},
} }
@ -28,7 +28,7 @@ func TestShellOptions_Command(t *testing.T) {
assert.Equal(t, []string{"ls", "-la"}, opts.Command) assert.Equal(t, []string{"ls", "-la"}, opts.Command)
} }
func TestShellOptions_ConsoleWithCommand(t *testing.T) { func TestShellOptions_ConsoleWithCommand_Good(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(t *testing.T) {
assert.Equal(t, []string{"echo", "hello"}, opts.Command) assert.Equal(t, []string{"echo", "hello"}, opts.Command)
} }
func TestShellOptions_EmptyCommand(t *testing.T) { func TestShellOptions_EmptyCommand_Good(t *testing.T) {
opts := ShellOptions{ opts := ShellOptions{
Command: []string{}, Command: []string{},
} }

View file

@ -2,38 +2,37 @@ package devenv
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
coreio "forge.lthn.ai/core/go-io" 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/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 os.Getenv("CORE_SKIP_SSH_SCAN") == "true" { if core.Env("CORE_SKIP_SSH_SCAN") == "true" {
return nil return nil
} }
home, err := os.UserHomeDir() home := coreutil.HomeDir()
if err != nil { if home == "" {
return coreerr.E("ensureHostKey", "get home dir", err) return coreerr.E("ensureHostKey", "get home dir", nil)
} }
knownHostsPath := filepath.Join(home, ".core", "known_hosts") knownHostsPath := coreutil.JoinPath(home, ".core", "known_hosts")
// Ensure directory exists // Ensure directory exists
if err := coreio.Local.EnsureDir(filepath.Dir(knownHostsPath)); err != nil { if err := coreio.Local.EnsureDir(core.PathDir(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 := exec.CommandContext(ctx, "ssh-keyscan", "-p", fmt.Sprintf("%d", port), "localhost") cmd := proc.NewCommandContext(ctx, "ssh-keyscan", "-p", core.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)
@ -46,21 +45,27 @@ 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 := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) f, err := coreio.Local.Append(knownHostsPath)
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 := strings.Split(string(out), "\n") lines := core.Split(string(out), "\n")
for _, line := range lines { for _, line := range lines {
line = strings.TrimSpace(line) line = core.Trim(line)
if line == "" || strings.HasPrefix(line, "#") { if line == "" || core.HasPrefix(line, "#") {
continue continue
} }
if !strings.Contains(existingStr, line) { if !core.Contains(existingStr, line) {
if _, err := f.WriteString(line + "\n"); err != nil { if _, err := f.Write([]byte(core.Concat(line, "\n"))); err != nil {
return coreerr.E("ensureHostKey", "write known_hosts", err) return coreerr.E("ensureHostKey", "write known_hosts", err)
} }
} }

View file

@ -2,13 +2,13 @@ package devenv
import ( import (
"context" "context"
"encoding/json"
"path/filepath"
"strings"
"forge.lthn.ai/core/go-io" core "dappco.re/go/core"
coreerr "forge.lthn.ai/core/go-log" "dappco.re/go/core/io"
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 = strings.Join(opts.Command, " ") cmd = core.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,6 +72,10 @@ 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)
@ -112,12 +116,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) {
path := filepath.Join(projectDir, ".core", "test.yaml") absPath := coreutil.AbsPath(coreutil.JoinPath(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 {
@ -133,20 +137,12 @@ 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 {
path := filepath.Join(dir, name) absPath := coreutil.AbsPath(coreutil.JoinPath(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 {
path := filepath.Join(projectDir, "package.json") absPath := coreutil.AbsPath(coreutil.JoinPath(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 {
@ -156,7 +152,8 @@ 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"`
} }
if err := json.Unmarshal([]byte(content), &pkg); err != nil { result := core.JSONUnmarshalString(content, &pkg)
if !result.OK {
return false return false
} }
@ -165,11 +162,7 @@ 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 {
path := filepath.Join(projectDir, "composer.json") absPath := coreutil.AbsPath(coreutil.JoinPath(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 {
@ -179,7 +172,8 @@ 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"`
} }
if err := json.Unmarshal([]byte(content), &pkg); err != nil { result := core.JSONUnmarshalString(content, &pkg)
if !result.OK {
return false return false
} }

View file

@ -1,16 +1,15 @@
package devenv package devenv
import ( import (
"os"
"path/filepath"
"testing" "testing"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/container/internal/coreutil"
"dappco.re/go/core/io"
) )
func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) { func TestDetectTestCommand_ComposerJSON_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"scripts":{"test":"pest"}}`)
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "composer test" { if cmd != "composer test" {
@ -18,9 +17,9 @@ func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) {
} }
} }
func TestDetectTestCommand_Good_PackageJSON(t *testing.T) { func TestDetectTestCommand_PackageJSON_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"scripts":{"test":"vitest"}}`)
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "npm test" { if cmd != "npm test" {
@ -28,9 +27,9 @@ func TestDetectTestCommand_Good_PackageJSON(t *testing.T) {
} }
} }
func TestDetectTestCommand_Good_GoMod(t *testing.T) { func TestDetectTestCommand_GoMod_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example")
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "go test ./..." { if cmd != "go test ./..." {
@ -38,11 +37,11 @@ func TestDetectTestCommand_Good_GoMod(t *testing.T) {
} }
} }
func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) { func TestDetectTestCommand_CoreTestYaml_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
coreDir := filepath.Join(tmpDir, ".core") coreDir := coreutil.JoinPath(tmpDir, ".core")
_ = os.MkdirAll(coreDir, 0755) _ = io.Local.EnsureDir(coreDir)
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644) _ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "command: custom-test")
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "custom-test" { if cmd != "custom-test" {
@ -50,9 +49,9 @@ func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) {
} }
} }
func TestDetectTestCommand_Good_Pytest(t *testing.T) { func TestDetectTestCommand_Pytest_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "pytest.ini"), []byte("[pytest]"), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "pytest.ini"), "[pytest]")
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "pytest" { if cmd != "pytest" {
@ -60,9 +59,9 @@ func TestDetectTestCommand_Good_Pytest(t *testing.T) {
} }
} }
func TestDetectTestCommand_Good_Taskfile(t *testing.T) { func TestDetectTestCommand_Taskfile_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), []byte("version: '3'"), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "Taskfile.yaml"), "version: '3'")
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "task test" { if cmd != "task test" {
@ -70,7 +69,7 @@ func TestDetectTestCommand_Good_Taskfile(t *testing.T) {
} }
} }
func TestDetectTestCommand_Bad_NoFiles(t *testing.T) { func TestDetectTestCommand_NoFiles_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
@ -79,13 +78,13 @@ func TestDetectTestCommand_Bad_NoFiles(t *testing.T) {
} }
} }
func TestDetectTestCommand_Good_Priority(t *testing.T) { func TestDetectTestCommand_Priority_Good(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 := filepath.Join(tmpDir, ".core") coreDir := coreutil.JoinPath(tmpDir, ".core")
_ = os.MkdirAll(coreDir, 0755) _ = io.Local.EnsureDir(coreDir)
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: my-custom-test"), 0644) _ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "command: my-custom-test")
_ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example")
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "my-custom-test" { if cmd != "my-custom-test" {
@ -93,10 +92,10 @@ func TestDetectTestCommand_Good_Priority(t *testing.T) {
} }
} }
func TestLoadTestConfig_Good(t *testing.T) { func TestTest_LoadTestConfig_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
coreDir := filepath.Join(tmpDir, ".core") coreDir := coreutil.JoinPath(tmpDir, ".core")
_ = os.MkdirAll(coreDir, 0755) _ = io.Local.EnsureDir(coreDir)
configYAML := `version: 1 configYAML := `version: 1
command: default-test command: default-test
@ -108,7 +107,7 @@ commands:
env: env:
CI: "true" CI: "true"
` `
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte(configYAML), 0644) _ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), configYAML)
cfg, err := LoadTestConfig(io.Local, tmpDir) cfg, err := LoadTestConfig(io.Local, tmpDir)
if err != nil { if err != nil {
@ -132,7 +131,7 @@ env:
} }
} }
func TestLoadTestConfig_Bad_NotFound(t *testing.T) { func TestLoadTestConfig_NotFound_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_, err := LoadTestConfig(io.Local, tmpDir) _, err := LoadTestConfig(io.Local, tmpDir)
@ -141,9 +140,9 @@ func TestLoadTestConfig_Bad_NotFound(t *testing.T) {
} }
} }
func TestHasPackageScript_Good(t *testing.T) { func TestTest_HasPackageScript_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"jest","build":"webpack"}}`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"scripts":{"test":"jest","build":"webpack"}}`)
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")
@ -153,34 +152,34 @@ func TestHasPackageScript_Good(t *testing.T) {
} }
} }
func TestHasPackageScript_Bad_MissingScript(t *testing.T) { func TestHasPackageScript_MissingScript_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"build":"webpack"}}`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"scripts":{"build":"webpack"}}`)
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 TestHasComposerScript_Good(t *testing.T) { func TestTest_HasComposerScript_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`)
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_Bad_MissingScript(t *testing.T) { func TestHasComposerScript_MissingScript_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"build":"@php build.php"}}`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"scripts":{"build":"@php build.php"}}`)
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(t *testing.T) { func TestTestConfig_Struct_Good(t *testing.T) {
cfg := &TestConfig{ cfg := &TestConfig{
Version: 2, Version: 2,
Command: "my-test", Command: "my-test",
@ -201,7 +200,7 @@ func TestTestConfig_Struct(t *testing.T) {
} }
} }
func TestTestCommand_Struct(t *testing.T) { func TestTestCommand_Struct_Good(t *testing.T) {
cmd := TestCommand{ cmd := TestCommand{
Name: "integration", Name: "integration",
Run: "go test -tags=integration ./...", Run: "go test -tags=integration ./...",
@ -214,7 +213,7 @@ func TestTestCommand_Struct(t *testing.T) {
} }
} }
func TestTestOptions_Struct(t *testing.T) { func TestTestOptions_Struct_Good(t *testing.T) {
opts := TestOptions{ opts := TestOptions{
Name: "unit", Name: "unit",
Command: []string{"go", "test", "-v"}, Command: []string{"go", "test", "-v"},
@ -227,9 +226,9 @@ func TestTestOptions_Struct(t *testing.T) {
} }
} }
func TestDetectTestCommand_Good_TaskfileYml(t *testing.T) { func TestDetectTestCommand_TaskfileYml_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yml"), []byte("version: '3'"), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "Taskfile.yml"), "version: '3'")
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "task test" { if cmd != "task test" {
@ -237,9 +236,9 @@ func TestDetectTestCommand_Good_TaskfileYml(t *testing.T) {
} }
} }
func TestDetectTestCommand_Good_Pyproject(t *testing.T) { func TestDetectTestCommand_Pyproject_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "pyproject.toml"), []byte("[tool.pytest]"), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "pyproject.toml"), "[tool.pytest]")
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "pytest" { if cmd != "pytest" {
@ -247,7 +246,7 @@ func TestDetectTestCommand_Good_Pyproject(t *testing.T) {
} }
} }
func TestHasPackageScript_Bad_NoFile(t *testing.T) { func TestHasPackageScript_NoFile_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
if hasPackageScript(io.Local, tmpDir, "test") { if hasPackageScript(io.Local, tmpDir, "test") {
@ -255,25 +254,25 @@ func TestHasPackageScript_Bad_NoFile(t *testing.T) {
} }
} }
func TestHasPackageScript_Bad_InvalidJSON(t *testing.T) { func TestHasPackageScript_InvalidJSON_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`invalid json`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `invalid json`)
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_Bad_NoScripts(t *testing.T) { func TestHasPackageScript_NoScripts_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"name":"test"}`)
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_Bad_NoFile(t *testing.T) { func TestHasComposerScript_NoFile_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
if hasComposerScript(io.Local, tmpDir, "test") { if hasComposerScript(io.Local, tmpDir, "test") {
@ -281,29 +280,29 @@ func TestHasComposerScript_Bad_NoFile(t *testing.T) {
} }
} }
func TestHasComposerScript_Bad_InvalidJSON(t *testing.T) { func TestHasComposerScript_InvalidJSON_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`invalid json`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `invalid json`)
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_Bad_NoScripts(t *testing.T) { func TestHasComposerScript_NoScripts_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"name":"test/pkg"}`)
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_Bad_InvalidYAML(t *testing.T) { func TestLoadTestConfig_InvalidYAML_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
coreDir := filepath.Join(tmpDir, ".core") coreDir := coreutil.JoinPath(tmpDir, ".core")
_ = os.MkdirAll(coreDir, 0755) _ = io.Local.EnsureDir(coreDir)
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("invalid: yaml: :"), 0644) _ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "invalid: yaml: :")
_, err := LoadTestConfig(io.Local, tmpDir) _, err := LoadTestConfig(io.Local, tmpDir)
if err == nil { if err == nil {
@ -311,11 +310,11 @@ func TestLoadTestConfig_Bad_InvalidYAML(t *testing.T) {
} }
} }
func TestLoadTestConfig_Good_MinimalConfig(t *testing.T) { func TestLoadTestConfig_MinimalConfig_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
coreDir := filepath.Join(tmpDir, ".core") coreDir := coreutil.JoinPath(tmpDir, ".core")
_ = os.MkdirAll(coreDir, 0755) _ = io.Local.EnsureDir(coreDir)
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("version: 1"), 0644) _ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "version: 1")
cfg, err := LoadTestConfig(io.Local, tmpDir) cfg, err := LoadTestConfig(io.Local, tmpDir)
if err != nil { if err != nil {
@ -329,10 +328,10 @@ func TestLoadTestConfig_Good_MinimalConfig(t *testing.T) {
} }
} }
func TestDetectTestCommand_Good_ComposerWithoutScript(t *testing.T) { func TestDetectTestCommand_ComposerWithoutScript_Good(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
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"name":"test/pkg"}`)
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
// Falls through to empty (no match) // Falls through to empty (no match)
@ -341,10 +340,10 @@ func TestDetectTestCommand_Good_ComposerWithoutScript(t *testing.T) {
} }
} }
func TestDetectTestCommand_Good_PackageJSONWithoutScript(t *testing.T) { func TestDetectTestCommand_PackageJSONWithoutScript_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
// package.json without test or dev script // package.json without test or dev script
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"name":"test"}`)
cmd := DetectTestCommand(io.Local, tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
// Falls through to empty // Falls through to empty

View file

@ -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 (go-io, config, go-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 (core/io, config, core/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 a `_Good`, `_Bad`, `_Ugly` suffix pattern: Tests follow the `TestSubject_Function_{Good,Bad,Ugly}` pattern:
| Suffix | Meaning | | Suffix | Meaning |
|--------|---------| |--------|---------|
@ -51,9 +51,9 @@ Tests follow a `_Good`, `_Bad`, `_Ugly` suffix pattern:
Examples from the codebase: Examples from the codebase:
```go ```go
func TestNewState_Good(t *testing.T) { /* creates state successfully */ } func TestState_NewState_Good(t *testing.T) { /* creates state successfully */ }
func TestLoadState_Bad_InvalidJSON(t *testing.T) { /* handles corrupt state file */ } func TestLoadState_InvalidJSON_Bad(t *testing.T) { /* handles corrupt state file */ }
func TestGetHypervisor_Bad_Unknown(t *testing.T) { /* rejects unknown hypervisor name */ } func TestGetHypervisor_Unknown_Bad(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 `fmt.Errorf("context: %w", err)` for all error returns. - **Error wrapping** -- Use `core.E("Op", "message", err)` rather than `fmt.Errorf`.
- **`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. - **`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.
- **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) (*exec.Cmd, error) { func (h *MyHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) {
// Build and return exec.Cmd // Build and return the command.
} }
``` ```
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 `_Good`/`_Bad` naming convention. 3. Add tests following the `TestSubject_Function_{Good,Bad,Ugly}` naming convention.
## Adding a new image source ## Adding a new image source

View file

@ -5,7 +5,7 @@ description: Container runtime, LinuxKit image builder, and portable development
# go-container # go-container
`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. `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.
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
``` ```
forge.lthn.ai/core/go-container dappco.re/go/core/container
``` ```
Requires **Go 1.26+**. Requires **Go 1.26+**.
@ -26,8 +26,8 @@ Requires **Go 1.26+**.
```go ```go
import ( import (
"context" "context"
container "forge.lthn.ai/core/go-container" container "dappco.re/go/core/container"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/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 (
"forge.lthn.ai/core/go-container/devenv" "dappco.re/go/core/container/devenv"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/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 "forge.lthn.ai/core/go-container" import container "dappco.re/go/core/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) | `forge.lthn.ai/core/go-container` | Container struct, Manager interface, hypervisor abstraction, LinuxKit manager, state persistence, template engine | | `container` (root) | `dappco.re/go/core/container` | Container struct, Manager interface, hypervisor abstraction, LinuxKit manager, state persistence, template engine |
| `devenv` | `forge.lthn.ai/core/go-container/devenv` | Portable dev environment orchestration: boot, shell, serve, test, Claude sandbox, image management | | `devenv` | `dappco.re/go/core/container/devenv` | Portable dev environment orchestration: boot, shell, serve, test, Claude sandbox, image management |
| `sources` | `forge.lthn.ai/core/go-container/sources` | Image download backends: CDN and GitHub Releases with progress reporting | | `sources` | `dappco.re/go/core/container/sources` | Image download backends: CDN and GitHub Releases with progress reporting |
| `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`) | | `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`) |
## Dependencies ## Dependencies
| Module | Purpose | | Module | Purpose |
|--------|---------| |--------|---------|
| `forge.lthn.ai/core/go-io` | File system abstraction (`Medium` interface), process utilities | | `dappco.re/go/core/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`) |
| `forge.lthn.ai/core/go-i18n` | Internationalised UI strings (used by `cmd/vm`) | | `dappco.re/go/core/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: `go-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: `core/io` and the standard library. The `devenv` and `cmd/vm` packages pull in the heavier dependencies.
## CLI commands ## CLI commands

12
go.mod
View file

@ -1,20 +1,24 @@
module forge.lthn.ai/core/go-container module dappco.re/go/core/container
go 1.26.0 go 1.26.0
require ( require (
dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/i18n v0.2.0
dappco.re/go/core/io v0.2.0
dappco.re/go/core/log v0.1.0
forge.lthn.ai/core/cli v0.3.7 forge.lthn.ai/core/cli v0.3.7
forge.lthn.ai/core/config v0.1.8 forge.lthn.ai/core/config v0.1.8
forge.lthn.ai/core/go-i18n v0.1.7
forge.lthn.ai/core/go-io v0.1.7
forge.lthn.ai/core/go-log v0.0.4
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.3 // indirect
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
forge.lthn.ai/core/go-inference v0.1.6 // indirect forge.lthn.ai/core/go-inference v0.1.6 // indirect
forge.lthn.ai/core/go-io v0.1.7 // indirect
forge.lthn.ai/core/go-log v0.0.4 // 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

8
go.sum
View file

@ -1,3 +1,11 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg= forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=

View file

@ -2,14 +2,13 @@ package container
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime" "runtime"
"strings"
coreerr "forge.lthn.ai/core/go-log" core "dappco.re/go/core"
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.
@ -19,7 +18,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) (*exec.Cmd, error) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error)
} }
// HypervisorOptions contains options for running a VM. // HypervisorOptions contains options for running a VM.
@ -47,6 +46,10 @@ 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",
@ -60,20 +63,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 := exec.LookPath(q.Binary) _, err := proc.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) (*exec.Cmd, error) { func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, 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", fmt.Sprintf("%d", opts.Memory), "-m", core.Sprintf("%d", opts.Memory),
"-smp", fmt.Sprintf("%d", opts.CPUs), "-smp", core.Sprintf("%d", opts.CPUs),
"-enable-kvm", "-enable-kvm",
} }
@ -83,11 +86,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", fmt.Sprintf("file=%s,format=qcow2", image)) args = append(args, "-drive", core.Sprintf("file=%s,format=qcow2", image))
case FormatVMDK: case FormatVMDK:
args = append(args, "-drive", fmt.Sprintf("file=%s,format=vmdk", image)) args = append(args, "-drive", core.Sprintf("file=%s,format=vmdk", image))
case FormatRaw: case FormatRaw:
args = append(args, "-drive", fmt.Sprintf("file=%s,format=raw", image)) args = append(args, "-drive", core.Sprintf("file=%s,format=raw", image))
} }
// Always run in nographic mode for container-like behavior // Always run in nographic mode for container-like behavior
@ -99,10 +102,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 += fmt.Sprintf(",hostfwd=tcp::%d-:22", opts.SSHPort) netdev += core.Sprintf(",hostfwd=tcp::%d-:22", opts.SSHPort)
} }
for hostPort, guestPort := range opts.Ports { for hostPort, guestPort := range opts.Ports {
netdev += fmt.Sprintf(",hostfwd=tcp::%d-:%d", hostPort, guestPort) netdev += core.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")
@ -110,10 +113,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 := fmt.Sprintf("share%d", shareID) tag := core.Sprintf("share%d", shareID)
args = append(args, args = append(args,
"-fsdev", fmt.Sprintf("local,id=%s,path=%s,security_model=none", tag, hostPath), "-fsdev", core.Sprintf("local,id=%s,path=%s,security_model=none", tag, hostPath),
"-device", fmt.Sprintf("virtio-9p-pci,fsdev=%s,mount_tag=%s", tag, filepath.Base(guestPath)), "-device", core.Sprintf("virtio-9p-pci,fsdev=%s,mount_tag=%s", tag, core.PathBase(guestPath)),
) )
shareID++ shareID++
} }
@ -135,14 +138,12 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H
} }
} }
cmd := exec.CommandContext(ctx, q.Binary, args...) return proc.NewCommandContext(ctx, q.Binary, args...), nil
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 {
_, err := os.Stat("/dev/kvm") return coreio.Local.Exists("/dev/kvm")
return err == nil
} }
// HyperkitHypervisor implements Hypervisor for macOS Hyperkit. // HyperkitHypervisor implements Hypervisor for macOS Hyperkit.
@ -152,6 +153,10 @@ 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",
@ -168,20 +173,20 @@ func (h *HyperkitHypervisor) Available() bool {
if runtime.GOOS != "darwin" { if runtime.GOOS != "darwin" {
return false return false
} }
_, err := exec.LookPath(h.Binary) _, err := proc.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) (*exec.Cmd, error) { func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, 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", fmt.Sprintf("%dM", opts.Memory), "-m", core.Sprintf("%dM", opts.Memory),
"-c", fmt.Sprintf("%d", opts.CPUs), "-c", core.Sprintf("%d", opts.CPUs),
"-A", // ACPI "-A", // ACPI
"-u", // Unlimited console output "-u", // Unlimited console output
"-s", "0:0,hostbridge", "-s", "0:0,hostbridge",
@ -192,9 +197,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", fmt.Sprintf("2:0,ahci-cd,%s", image)) args = append(args, "-s", core.Sprintf("2:0,ahci-cd,%s", image))
case FormatQCOW2, FormatVMDK, FormatRaw: case FormatQCOW2, FormatVMDK, FormatRaw:
args = append(args, "-s", fmt.Sprintf("2:0,virtio-blk,%s", image)) args = append(args, "-s", core.Sprintf("2:0,virtio-blk,%s", image))
} }
// Network with port forwarding (slot 3) // Network with port forwarding (slot 3)
@ -203,24 +208,27 @@ 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, fmt.Sprintf("tcp:%d:22", opts.SSHPort)) portForwards = append(portForwards, core.Sprintf("tcp:%d:22", opts.SSHPort))
} }
for hostPort, guestPort := range opts.Ports { for hostPort, guestPort := range opts.Ports {
portForwards = append(portForwards, fmt.Sprintf("tcp:%d:%d", hostPort, guestPort)) portForwards = append(portForwards, core.Sprintf("tcp:%d:%d", hostPort, guestPort))
} }
if len(portForwards) > 0 { if len(portForwards) > 0 {
netArgs += "," + strings.Join(portForwards, ",") netArgs += "," + core.Join(",", portForwards...)
} }
} }
args = append(args, "-s", "3:0,"+netArgs) args = append(args, "-s", "3:0,"+netArgs)
cmd := exec.CommandContext(ctx, h.Binary, args...) return proc.NewCommandContext(ctx, h.Binary, args...), nil
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 := strings.ToLower(filepath.Ext(path)) ext := core.Lower(core.PathExt(path))
switch ext { switch ext {
case ".iso": case ".iso":
return FormatISO return FormatISO
@ -236,6 +244,10 @@ 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" {
@ -255,8 +267,12 @@ 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 strings.ToLower(name) { switch core.Lower(name) {
case "qemu": case "qemu":
h := NewQemuHypervisor() h := NewQemuHypervisor()
if !h.Available() { if !h.Available() {

View file

@ -20,7 +20,7 @@ func TestQemuHypervisor_Available_Good(t *testing.T) {
assert.IsType(t, true, available) assert.IsType(t, true, available)
} }
func TestQemuHypervisor_Available_Bad_InvalidBinary(t *testing.T) { func TestQemuHypervisor_Available_InvalidBinary_Bad(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_Bad_NotDarwin(t *testing.T) { func TestHyperkitHypervisor_Available_NotDarwin_Bad(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_Bad_NotDarwin(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_Bad_InvalidBinary(t *testing.T) { func TestHyperkitHypervisor_Available_InvalidBinary_Bad(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_Bad_InvalidBinary(t *testing.T) {
assert.False(t, available) assert.False(t, available)
} }
func TestIsKVMAvailable_Good(t *testing.T) { func TestHypervisor_IsKVMAvailable_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 TestIsKVMAvailable_Good(t *testing.T) {
} }
} }
func TestDetectHypervisor_Good(t *testing.T) { func TestHypervisor_DetectHypervisor_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 TestDetectHypervisor_Good(t *testing.T) {
} }
} }
func TestGetHypervisor_Good_Qemu(t *testing.T) { func TestGetHypervisor_Qemu_Good(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_Good_Qemu(t *testing.T) {
} }
} }
func TestGetHypervisor_Good_QemuUppercase(t *testing.T) { func TestGetHypervisor_QemuUppercase_Good(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_Good_QemuUppercase(t *testing.T) {
} }
} }
func TestGetHypervisor_Good_Hyperkit(t *testing.T) { func TestGetHypervisor_Hyperkit_Good(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_Good_Hyperkit(t *testing.T) {
} }
} }
func TestGetHypervisor_Bad_Unknown(t *testing.T) { func TestGetHypervisor_Unknown_Bad(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_Good_WithPortsAndVolumes(t *testing.T) { func TestQemuHypervisor_BuildCommand_WithPortsAndVolumes_Good(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -172,7 +172,7 @@ func TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes(t *testing.T) {
assert.Contains(t, args, "4") assert.Contains(t, args, "4")
} }
func TestQemuHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { func TestQemuHypervisor_BuildCommand_QCow2Format_Good(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -192,7 +192,7 @@ func TestQemuHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) {
assert.True(t, found, "Should have qcow2 drive argument") assert.True(t, found, "Should have qcow2 drive argument")
} }
func TestQemuHypervisor_BuildCommand_Good_VMDKFormat(t *testing.T) { func TestQemuHypervisor_BuildCommand_VMDKFormat_Good(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -212,7 +212,7 @@ func TestQemuHypervisor_BuildCommand_Good_VMDKFormat(t *testing.T) {
assert.True(t, found, "Should have vmdk drive argument") assert.True(t, found, "Should have vmdk drive argument")
} }
func TestQemuHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { func TestQemuHypervisor_BuildCommand_RawFormat_Good(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -232,7 +232,7 @@ func TestQemuHypervisor_BuildCommand_Good_RawFormat(t *testing.T) {
assert.True(t, found, "Should have raw drive argument") assert.True(t, found, "Should have raw drive argument")
} }
func TestHyperkitHypervisor_BuildCommand_Good_WithPorts(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_WithPorts_Good(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()
@ -255,7 +255,7 @@ func TestHyperkitHypervisor_BuildCommand_Good_WithPorts(t *testing.T) {
assert.Contains(t, args, "2") assert.Contains(t, args, "2")
} }
func TestHyperkitHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_QCow2Format_Good(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()
@ -266,7 +266,7 @@ func TestHyperkitHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) {
assert.NotNil(t, cmd) assert.NotNil(t, cmd)
} }
func TestHyperkitHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_RawFormat_Good(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()
@ -277,7 +277,7 @@ func TestHyperkitHypervisor_BuildCommand_Good_RawFormat(t *testing.T) {
assert.NotNil(t, cmd) assert.NotNil(t, cmd)
} }
func TestHyperkitHypervisor_BuildCommand_Good_NoPorts(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_NoPorts_Good(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()
@ -293,7 +293,7 @@ func TestHyperkitHypervisor_BuildCommand_Good_NoPorts(t *testing.T) {
assert.NotNil(t, cmd) assert.NotNil(t, cmd)
} }
func TestQemuHypervisor_BuildCommand_Good_NoSSHPort(t *testing.T) { func TestQemuHypervisor_BuildCommand_NoSSHPort_Good(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -309,7 +309,7 @@ func TestQemuHypervisor_BuildCommand_Good_NoSSHPort(t *testing.T) {
assert.NotNil(t, cmd) assert.NotNil(t, cmd)
} }
func TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { func TestQemuHypervisor_BuildCommand_UnknownFormat_Bad(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -320,7 +320,7 @@ func TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) {
assert.Contains(t, err.Error(), "unknown image format") assert.Contains(t, err.Error(), "unknown image format")
} }
func TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_UnknownFormat_Bad(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_Good_ISOFormat(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_ISOFormat_Good(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()

View file

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

401
internal/proc/proc.go Normal file
View file

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

View file

@ -1,6 +1,6 @@
# go-container # go-container
Module: `forge.lthn.ai/core/go-container` Module: `dappco.re/go/core/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 "forge.lthn.ai/core/go-container" import "dappco.re/go/core/container"
// Auto-detect hypervisor // Auto-detect hypervisor
hv, _ := container.DetectHypervisor() hv, _ := container.DetectHypervisor()

View file

@ -1,6 +1,6 @@
# Hypervisors # Hypervisors
Module: `forge.lthn.ai/core/go-container` Module: `dappco.re/go/core/container`
## Interface ## Interface

View file

@ -3,15 +3,15 @@ package container
import ( import (
"bufio" "bufio"
"context" "context"
"fmt"
goio "io" goio "io"
"os"
"os/exec"
"syscall" "syscall"
"time" "time"
"forge.lthn.ai/core/go-io" core "dappco.re/go/core"
coreerr "forge.lthn.ai/core/go-log" "dappco.re/go/core/io"
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,6 +22,10 @@ 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 {
@ -46,6 +50,10 @@ 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,
@ -119,7 +127,7 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
} }
// Create log file // Create log file
logFile, err := os.Create(logPath) logFile, err := io.Local.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)
} }
@ -196,11 +204,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, os.Stdout) mw := goio.MultiWriter(logFile, proc.Stdout)
_, _ = goio.Copy(mw, stdout) _, _ = goio.Copy(mw, stdout)
}() }()
go func() { go func() {
mw := goio.MultiWriter(logFile, os.Stderr) mw := goio.MultiWriter(logFile, proc.Stderr)
_, _ = goio.Copy(mw, stderr) _, _ = goio.Copy(mw, stderr)
}() }()
@ -220,7 +228,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 *exec.Cmd) { func (m *LinuxKitManager) waitForExit(id string, cmd *proc.Command) {
err := cmd.Wait() err := cmd.Wait()
container, ok := m.state.Get(id) container, ok := m.state.Get(id)
@ -249,16 +257,7 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
} }
// Find the process // Find the process
process, err := os.FindProcess(container.PID) if err := syscall.Kill(container.PID, syscall.SIGTERM); err != nil {
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)
@ -267,28 +266,23 @@ 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 {
_ = process.Signal(syscall.SIGKILL) _ = syscall.Kill(container.PID, syscall.SIGKILL)
return err return err
} }
// Wait for graceful shutdown with timeout deadline := time.After(10 * time.Second)
done := make(chan struct{}) ticker := time.NewTicker(100 * time.Millisecond)
go func() { defer ticker.Stop()
_, _ = process.Wait()
close(done)
}()
select { for isProcessRunning(container.PID) {
case <-done: select {
// Process exited gracefully case <-deadline:
case <-time.After(10 * time.Second): _ = syscall.Kill(container.PID, syscall.SIGKILL)
// Force kill case <-ctx.Done():
_ = process.Signal(syscall.SIGKILL) _ = syscall.Kill(container.PID, syscall.SIGKILL)
<-done return ctx.Err()
case <-ctx.Done(): case <-ticker.C:
// Context cancelled }
_ = process.Signal(syscall.SIGKILL)
return ctx.Err()
} }
container.Status = StatusStopped container.Status = StatusStopped
@ -317,14 +311,10 @@ 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 {
process, err := os.FindProcess(pid) if pid <= 0 {
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.
@ -436,7 +426,7 @@ func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) err
// Build SSH command // Build SSH command
sshArgs := []string{ sshArgs := []string{
"-p", fmt.Sprintf("%d", sshPort), "-p", core.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",
@ -444,10 +434,10 @@ func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) err
} }
sshArgs = append(sshArgs, cmd...) sshArgs = append(sshArgs, cmd...)
sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...) sshCmd := proc.NewCommandContext(ctx, "ssh", sshArgs...)
sshCmd.Stdin = os.Stdin sshCmd.Stdin = proc.Stdin
sshCmd.Stdout = os.Stdout sshCmd.Stdout = proc.Stdout
sshCmd.Stderr = os.Stderr sshCmd.Stderr = proc.Stderr
return sshCmd.Run() return sshCmd.Run()
} }

View file

@ -2,13 +2,14 @@ package container
import ( import (
"context" "context"
"os" "syscall"
"os/exec"
"path/filepath"
"testing" "testing"
"time" "time"
"forge.lthn.ai/core/go-io" core "dappco.re/go/core"
"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"
) )
@ -39,30 +40,30 @@ func (m *MockHypervisor) Available() bool {
return m.available return m.available
} }
func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, 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 exec.CommandContext(ctx, m.commandToRun, "test"), nil return proc.NewCommandContext(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 := os.MkdirTemp("", "linuxkit-test-*") tmpDir, err := coreutil.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)
_ = os.RemoveAll(tmpDir) _ = io.Local.DeleteAll(tmpDir)
}) })
statePath := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(tmpDir, "containers.json")
state, err := LoadState(statePath) state, err := LoadState(statePath)
require.NoError(t, err) require.NoError(t, err)
@ -73,9 +74,9 @@ func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) {
return manager, mock, tmpDir return manager, mock, tmpDir
} }
func TestNewLinuxKitManagerWithHypervisor_Good(t *testing.T) { func TestLinuxKit_NewLinuxKitManagerWithHypervisor_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(tmpDir, "containers.json")
state, _ := LoadState(statePath) state, _ := LoadState(statePath)
mock := NewMockHypervisor() mock := NewMockHypervisor()
@ -86,12 +87,12 @@ func TestNewLinuxKitManagerWithHypervisor_Good(t *testing.T) {
assert.Equal(t, mock, manager.Hypervisor()) assert.Equal(t, mock, manager.Hypervisor())
} }
func TestLinuxKitManager_Run_Good_Detached(t *testing.T) { func TestLinuxKitManager_Run_Detached_Good(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
// Create a test image file // Create a test image file
imagePath := filepath.Join(tmpDir, "test.iso") imagePath := coreutil.JoinPath(tmpDir, "test.iso")
err := os.WriteFile(imagePath, []byte("fake image"), 0644) err := io.Local.Write(imagePath, "fake image")
require.NoError(t, err) require.NoError(t, err)
// Use a command that runs briefly then exits // Use a command that runs briefly then exits
@ -125,11 +126,11 @@ func TestLinuxKitManager_Run_Good_Detached(t *testing.T) {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
func TestLinuxKitManager_Run_Good_DefaultValues(t *testing.T) { func TestLinuxKitManager_Run_DefaultValues_Good(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
imagePath := filepath.Join(tmpDir, "test.qcow2") imagePath := coreutil.JoinPath(tmpDir, "test.qcow2")
err := os.WriteFile(imagePath, []byte("fake image"), 0644) err := io.Local.Write(imagePath, "fake image")
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
@ -150,7 +151,7 @@ func TestLinuxKitManager_Run_Good_DefaultValues(t *testing.T) {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
} }
func TestLinuxKitManager_Run_Bad_ImageNotFound(t *testing.T) { func TestLinuxKitManager_Run_ImageNotFound_Bad(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
ctx := context.Background() ctx := context.Background()
@ -161,11 +162,11 @@ func TestLinuxKitManager_Run_Bad_ImageNotFound(t *testing.T) {
assert.Contains(t, err.Error(), "image not found") assert.Contains(t, err.Error(), "image not found")
} }
func TestLinuxKitManager_Run_Bad_UnsupportedFormat(t *testing.T) { func TestLinuxKitManager_Run_UnsupportedFormat_Bad(t *testing.T) {
manager, _, tmpDir := newTestManager(t) manager, _, tmpDir := newTestManager(t)
imagePath := filepath.Join(tmpDir, "test.txt") imagePath := coreutil.JoinPath(tmpDir, "test.txt")
err := os.WriteFile(imagePath, []byte("not an image"), 0644) err := io.Local.Write(imagePath, "not an image")
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
@ -201,7 +202,7 @@ func TestLinuxKitManager_Stop_Good(t *testing.T) {
assert.Equal(t, StatusStopped, c.Status) assert.Equal(t, StatusStopped, c.Status)
} }
func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) { func TestLinuxKitManager_Stop_NotFound_Bad(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
ctx := context.Background() ctx := context.Background()
@ -211,9 +212,9 @@ func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) {
assert.Contains(t, err.Error(), "container not found") assert.Contains(t, err.Error(), "container not found")
} }
func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) { func TestLinuxKitManager_Stop_NotRunning_Bad(t *testing.T) {
_, _, tmpDir := newTestManager(t) _, _, tmpDir := newTestManager(t)
statePath := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(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())
@ -233,7 +234,7 @@ func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) {
func TestLinuxKitManager_List_Good(t *testing.T) { func TestLinuxKitManager_List_Good(t *testing.T) {
_, _, tmpDir := newTestManager(t) _, _, tmpDir := newTestManager(t)
statePath := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(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())
@ -248,9 +249,9 @@ func TestLinuxKitManager_List_Good(t *testing.T) {
assert.Len(t, containers, 2) assert.Len(t, containers, 2)
} }
func TestLinuxKitManager_List_Good_VerifiesRunningStatus(t *testing.T) { func TestLinuxKitManager_List_VerifiesRunningStatus_Good(t *testing.T) {
_, _, tmpDir := newTestManager(t) _, _, tmpDir := newTestManager(t)
statePath := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(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())
@ -275,8 +276,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 := filepath.Join(tmpDir, "logs") logsDir := coreutil.JoinPath(tmpDir, "logs")
require.NoError(t, os.MkdirAll(logsDir, 0755)) require.NoError(t, io.Local.EnsureDir(logsDir))
container := &Container{ID: "abc12345"} container := &Container{ID: "abc12345"}
_ = manager.State().Add(container) _ = manager.State().Add(container)
@ -286,8 +287,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, os.MkdirAll(filepath.Dir(logPath), 0755)) require.NoError(t, io.Local.EnsureDir(core.PathDir(logPath)))
require.NoError(t, os.WriteFile(logPath, []byte(logContent), 0644)) require.NoError(t, io.Local.Write(logPath, logContent))
ctx := context.Background() ctx := context.Background()
reader, err := manager.Logs(ctx, "abc12345", false) reader, err := manager.Logs(ctx, "abc12345", false)
@ -300,7 +301,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_Bad_NotFound(t *testing.T) { func TestLinuxKitManager_Logs_NotFound_Bad(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
ctx := context.Background() ctx := context.Background()
@ -310,7 +311,7 @@ func TestLinuxKitManager_Logs_Bad_NotFound(t *testing.T) {
assert.Contains(t, err.Error(), "container not found") assert.Contains(t, err.Error(), "container not found")
} }
func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) { func TestLinuxKitManager_Logs_NoLogFile_Bad(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
@ -333,7 +334,7 @@ func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) {
} }
} }
func TestLinuxKitManager_Exec_Bad_NotFound(t *testing.T) { func TestLinuxKitManager_Exec_NotFound_Bad(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
ctx := context.Background() ctx := context.Background()
@ -343,7 +344,7 @@ func TestLinuxKitManager_Exec_Bad_NotFound(t *testing.T) {
assert.Contains(t, err.Error(), "container not found") assert.Contains(t, err.Error(), "container not found")
} }
func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) { func TestLinuxKitManager_Exec_NotRunning_Bad(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
container := &Container{ID: "abc12345", Status: StatusStopped} container := &Container{ID: "abc12345", Status: StatusStopped}
@ -356,7 +357,7 @@ func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) {
assert.Contains(t, err.Error(), "not running") assert.Contains(t, err.Error(), "not running")
} }
func TestDetectImageFormat_Good(t *testing.T) { func TestLinuxKit_DetectImageFormat_Good(t *testing.T) {
tests := []struct { tests := []struct {
path string path string
format ImageFormat format ImageFormat
@ -378,7 +379,7 @@ func TestDetectImageFormat_Good(t *testing.T) {
} }
} }
func TestDetectImageFormat_Bad_Unknown(t *testing.T) { func TestDetectImageFormat_Unknown_Bad(t *testing.T) {
tests := []string{ tests := []string{
"/path/to/image.txt", "/path/to/image.txt",
"/path/to/image", "/path/to/image",
@ -426,7 +427,7 @@ func TestQemuHypervisor_BuildCommand_Good(t *testing.T) {
assert.Contains(t, args, "-nographic") assert.Contains(t, args, "-nographic")
} }
func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) { func TestLinuxKitManager_Logs_Follow_Good(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
// Create a unique container ID // Create a unique container ID
@ -438,10 +439,10 @@ func TestLinuxKitManager_Logs_Good_Follow(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, os.MkdirAll(filepath.Dir(logPath), 0755)) require.NoError(t, io.Local.EnsureDir(core.PathDir(logPath)))
// Write initial content // Write initial content
err = os.WriteFile(logPath, []byte("initial log content\n"), 0644) err = io.Local.Write(logPath, "initial log content\n")
require.NoError(t, err) require.NoError(t, err)
// Create a cancellable context // Create a cancellable context
@ -464,13 +465,13 @@ func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) {
assert.NoError(t, reader.Close()) assert.NoError(t, reader.Close())
} }
func TestFollowReader_Read_Good_WithData(t *testing.T) { func TestFollowReader_Read_WithData_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
logPath := filepath.Join(tmpDir, "test.log") logPath := coreutil.JoinPath(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 := os.WriteFile(logPath, []byte(content), 0644) err := io.Local.Write(logPath, content)
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)
@ -481,9 +482,9 @@ func TestFollowReader_Read_Good_WithData(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 := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0644) f, err := io.Local.Append(logPath)
require.NoError(t, err) require.NoError(t, err)
_, err = f.WriteString("new line\n") _, err = f.Write([]byte("new line\n"))
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, f.Close()) require.NoError(t, f.Close())
@ -497,12 +498,12 @@ func TestFollowReader_Read_Good_WithData(t *testing.T) {
} }
} }
func TestFollowReader_Read_Good_ContextCancel(t *testing.T) { func TestFollowReader_Read_ContextCancel_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
logPath := filepath.Join(tmpDir, "test.log") logPath := coreutil.JoinPath(tmpDir, "test.log")
// Create log file // Create log file
err := os.WriteFile(logPath, []byte("initial content\n"), 0644) err := io.Local.Write(logPath, "initial content\n")
require.NoError(t, err) require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@ -523,9 +524,9 @@ func TestFollowReader_Read_Good_ContextCancel(t *testing.T) {
func TestFollowReader_Close_Good(t *testing.T) { func TestFollowReader_Close_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
logPath := filepath.Join(tmpDir, "test.log") logPath := coreutil.JoinPath(tmpDir, "test.log")
err := os.WriteFile(logPath, []byte("content\n"), 0644) err := io.Local.Write(logPath, "content\n")
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
@ -541,19 +542,19 @@ func TestFollowReader_Close_Good(t *testing.T) {
assert.Error(t, readErr) assert.Error(t, readErr)
} }
func TestNewFollowReader_Bad_FileNotFound(t *testing.T) { func TestNewFollowReader_FileNotFound_Bad(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_Bad_BuildCommandError(t *testing.T) { func TestLinuxKitManager_Run_BuildCommandError_Bad(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
// Create a test image file // Create a test image file
imagePath := filepath.Join(tmpDir, "test.iso") imagePath := coreutil.JoinPath(tmpDir, "test.iso")
err := os.WriteFile(imagePath, []byte("fake image"), 0644) err := io.Local.Write(imagePath, "fake image")
require.NoError(t, err) require.NoError(t, err)
// Configure mock to return an error // Configure mock to return an error
@ -567,12 +568,12 @@ func TestLinuxKitManager_Run_Bad_BuildCommandError(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_Good_Foreground(t *testing.T) { func TestLinuxKitManager_Run_Foreground_Good(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
// Create a test image file // Create a test image file
imagePath := filepath.Join(tmpDir, "test.iso") imagePath := coreutil.JoinPath(tmpDir, "test.iso")
err := os.WriteFile(imagePath, []byte("fake image"), 0644) err := io.Local.Write(imagePath, "fake image")
require.NoError(t, err) require.NoError(t, err)
// Use echo which exits quickly // Use echo which exits quickly
@ -595,12 +596,12 @@ func TestLinuxKitManager_Run_Good_Foreground(t *testing.T) {
assert.Equal(t, StatusStopped, container.Status) assert.Equal(t, StatusStopped, container.Status)
} }
func TestLinuxKitManager_Stop_Good_ContextCancelled(t *testing.T) { func TestLinuxKitManager_Stop_ContextCancelled_Good(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
// Create a test image file // Create a test image file
imagePath := filepath.Join(tmpDir, "test.iso") imagePath := coreutil.JoinPath(tmpDir, "test.iso")
err := os.WriteFile(imagePath, []byte("fake image"), 0644) err := io.Local.Write(imagePath, "fake image")
require.NoError(t, err) require.NoError(t, err)
// Use a command that takes a long time // Use a command that takes a long time
@ -632,23 +633,23 @@ func TestLinuxKitManager_Stop_Good_ContextCancelled(t *testing.T) {
assert.Equal(t, context.Canceled, err) assert.Equal(t, context.Canceled, err)
} }
func TestIsProcessRunning_Good_ExistingProcess(t *testing.T) { func TestIsProcessRunning_ExistingProcess_Good(t *testing.T) {
// Use our own PID which definitely exists // Use our own PID which definitely exists
running := isProcessRunning(os.Getpid()) running := isProcessRunning(syscall.Getpid())
assert.True(t, running) assert.True(t, running)
} }
func TestIsProcessRunning_Bad_NonexistentProcess(t *testing.T) { func TestIsProcessRunning_NonexistentProcess_Bad(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_Good_WithPortsAndVolumes(t *testing.T) { func TestLinuxKitManager_Run_WithPortsAndVolumes_Good(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
imagePath := filepath.Join(tmpDir, "test.iso") imagePath := coreutil.JoinPath(tmpDir, "test.iso")
err := os.WriteFile(imagePath, []byte("fake image"), 0644) err := io.Local.Write(imagePath, "fake image")
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
@ -673,12 +674,12 @@ func TestLinuxKitManager_Run_Good_WithPortsAndVolumes(t *testing.T) {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
} }
func TestFollowReader_Read_Bad_ReaderError(t *testing.T) { func TestFollowReader_Read_ReaderError_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
logPath := filepath.Join(tmpDir, "test.log") logPath := coreutil.JoinPath(tmpDir, "test.log")
// Create log file // Create log file
err := os.WriteFile(logPath, []byte("content\n"), 0644) err := io.Local.Write(logPath, "content\n")
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
@ -694,11 +695,11 @@ func TestFollowReader_Read_Bad_ReaderError(t *testing.T) {
assert.Error(t, readErr) assert.Error(t, readErr)
} }
func TestLinuxKitManager_Run_Bad_StartError(t *testing.T) { func TestLinuxKitManager_Run_StartError_Bad(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
imagePath := filepath.Join(tmpDir, "test.iso") imagePath := coreutil.JoinPath(tmpDir, "test.iso")
err := os.WriteFile(imagePath, []byte("fake image"), 0644) err := io.Local.Write(imagePath, "fake image")
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
@ -715,11 +716,11 @@ func TestLinuxKitManager_Run_Bad_StartError(t *testing.T) {
assert.Contains(t, err.Error(), "failed to start VM") assert.Contains(t, err.Error(), "failed to start VM")
} }
func TestLinuxKitManager_Run_Bad_ForegroundStartError(t *testing.T) { func TestLinuxKitManager_Run_ForegroundStartError_Bad(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
imagePath := filepath.Join(tmpDir, "test.iso") imagePath := coreutil.JoinPath(tmpDir, "test.iso")
err := os.WriteFile(imagePath, []byte("fake image"), 0644) err := io.Local.Write(imagePath, "fake image")
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
@ -736,11 +737,11 @@ func TestLinuxKitManager_Run_Bad_ForegroundStartError(t *testing.T) {
assert.Contains(t, err.Error(), "failed to start VM") assert.Contains(t, err.Error(), "failed to start VM")
} }
func TestLinuxKitManager_Run_Good_ForegroundWithError(t *testing.T) { func TestLinuxKitManager_Run_ForegroundWithError_Good(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
imagePath := filepath.Join(tmpDir, "test.iso") imagePath := coreutil.JoinPath(tmpDir, "test.iso")
err := os.WriteFile(imagePath, []byte("fake image"), 0644) err := io.Local.Write(imagePath, "fake image")
require.NoError(t, err) require.NoError(t, err)
// Use a command that exits with error // Use a command that exits with error
@ -759,7 +760,7 @@ func TestLinuxKitManager_Run_Good_ForegroundWithError(t *testing.T) {
assert.Equal(t, StatusError, container.Status) assert.Equal(t, StatusError, container.Status)
} }
func TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning(t *testing.T) { func TestLinuxKitManager_Stop_ProcessExitedWhileRunning_Good(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

View file

@ -2,14 +2,14 @@ package sources
import ( import (
"context" "context"
"fmt"
goio "io" goio "io"
"net/http" "net/http"
"os"
"path/filepath"
"forge.lthn.ai/core/go-io" core "dappco.re/go/core"
coreerr "forge.lthn.ai/core/go-log" "dappco.re/go/core/io"
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,6 +21,10 @@ 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}
} }
@ -38,7 +42,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 := fmt.Sprintf("%s/manifest.json", s.config.CDNURL) url := core.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
@ -56,7 +60,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 := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName) url := core.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 {
@ -70,7 +74,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", fmt.Sprintf("HTTP %d", resp.StatusCode), nil) return coreerr.E("cdn.Download", core.Sprintf("HTTP %d", resp.StatusCode), nil)
} }
// Ensure dest directory exists // Ensure dest directory exists
@ -79,8 +83,8 @@ func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, prog
} }
// Create destination file // Create destination file
destPath := filepath.Join(dest, s.config.ImageName) destPath := coreutil.JoinPath(dest, s.config.ImageName)
f, err := os.Create(destPath) f, err := m.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)
} }

View file

@ -2,18 +2,18 @@ package sources
import ( import (
"context" "context"
"fmt" goio "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"testing" "testing"
"forge.lthn.ai/core/go-io" core "dappco.re/go/core"
"dappco.re/go/core/container/internal/coreutil"
"dappco.re/go/core/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestCDNSource_Good_Available(t *testing.T) { func TestCDNSource_Available_Good(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_Good_Available(t *testing.T) {
assert.True(t, src.Available()) assert.True(t, src.Available())
} }
func TestCDNSource_Bad_NoURL(t *testing.T) { func TestCDNSource_NoURL_Bad(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)
_, _ = fmt.Fprint(w, `{"version": "1.2.3"}`) _, _ = goio.WriteString(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)
_, _ = fmt.Fprint(w, content) _, _ = goio.WriteString(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 := os.ReadFile(filepath.Join(dest, imageName)) data, err := io.Local.Read(coreutil.JoinPath(dest, imageName))
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, content, string(data)) assert.Equal(t, content, 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_Bad_NoManifest(t *testing.T) { func TestCDNSource_LatestVersion_NoManifest_Bad(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_Bad_NoManifest(t *testing.T) {
assert.Equal(t, "latest", version) assert.Equal(t, "latest", version)
} }
func TestCDNSource_LatestVersion_Bad_ServerError(t *testing.T) { func TestCDNSource_LatestVersion_ServerError_Bad(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_Bad_ServerError(t *testing.T) {
assert.Equal(t, "latest", version) assert.Equal(t, "latest", version)
} }
func TestCDNSource_Download_Good_NoProgress(t *testing.T) { func TestCDNSource_Download_NoProgress_Good(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", fmt.Sprintf("%d", len(content))) w.Header().Set("Content-Length", core.Sprintf("%d", len(content)))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, content) _, _ = goio.WriteString(w, content)
})) }))
defer server.Close() defer server.Close()
@ -166,12 +166,12 @@ func TestCDNSource_Download_Good_NoProgress(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 := os.ReadFile(filepath.Join(dest, "test.img")) data, err := io.Local.Read(coreutil.JoinPath(dest, "test.img"))
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, content, string(data)) assert.Equal(t, content, data)
} }
func TestCDNSource_Download_Good_LargeFile(t *testing.T) { func TestCDNSource_Download_LargeFile_Good(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_Good_LargeFile(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", fmt.Sprintf("%d", len(content))) w.Header().Set("Content-Length", core.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_Good_LargeFile(t *testing.T) {
assert.Equal(t, int64(len(content)), lastDownloaded) assert.Equal(t, int64(len(content)), lastDownloaded)
} }
func TestCDNSource_Download_Bad_HTTPErrorCodes(t *testing.T) { func TestCDNSource_Download_HTTPErrorCodes_Bad(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
statusCode int statusCode int
@ -230,17 +230,17 @@ func TestCDNSource_Download_Bad_HTTPErrorCodes(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(), fmt.Sprintf("HTTP %d", tc.statusCode)) assert.Contains(t, err.Error(), core.Sprintf("HTTP %d", tc.statusCode))
}) })
} }
} }
func TestCDNSource_InterfaceCompliance(t *testing.T) { func TestCDNSource_InterfaceCompliance_Good(t *testing.T) {
// Verify CDNSource implements ImageSource // Verify CDNSource implements ImageSource
var _ ImageSource = (*CDNSource)(nil) var _ ImageSource = (*CDNSource)(nil)
} }
func TestCDNSource_Config(t *testing.T) { func TestCDNSource_Config_Good(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(t *testing.T) {
assert.Equal(t, "my-image.qcow2", src.config.ImageName) assert.Equal(t, "my-image.qcow2", src.config.ImageName)
} }
func TestNewCDNSource_Good(t *testing.T) { func TestCDN_NewCDNSource_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 TestNewCDNSource_Good(t *testing.T) {
assert.Equal(t, cfg.CDNURL, src.config.CDNURL) assert.Equal(t, cfg.CDNURL, src.config.CDNURL)
} }
func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) { func TestCDNSource_Download_CreatesDestDir_Good(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)
_, _ = fmt.Fprint(w, content) _, _ = goio.WriteString(w, content)
})) }))
defer server.Close() defer server.Close()
tmpDir := t.TempDir() tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "nested", "dir") dest := coreutil.JoinPath(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_Good_CreatesDestDir(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// Verify nested dir was created // Verify nested dir was created
info, err := os.Stat(dest) info, err := io.Local.Stat(dest)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, info.IsDir()) assert.True(t, info.IsDir())
} }
func TestSourceConfig_Struct(t *testing.T) { func TestSourceConfig_Struct_Good(t *testing.T) {
cfg := SourceConfig{ cfg := SourceConfig{
GitHubRepo: "owner/repo", GitHubRepo: "owner/repo",
RegistryImage: "ghcr.io/owner/image", RegistryImage: "ghcr.io/owner/image",

View file

@ -2,12 +2,12 @@ package sources
import ( import (
"context" "context"
"os"
"os/exec"
"strings"
"forge.lthn.ai/core/go-io" core "dappco.re/go/core"
coreerr "forge.lthn.ai/core/go-log" "dappco.re/go/core/io"
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,6 +19,10 @@ 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}
} }
@ -30,18 +34,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 := exec.LookPath("gh") _, err := proc.LookPath("gh")
if err != nil { if err != nil {
return false return false
} }
// Check if authenticated // Check if authenticated
cmd := exec.Command("gh", "auth", "status") cmd := proc.NewCommand("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 := exec.CommandContext(ctx, "gh", "release", "view", cmd := proc.NewCommandContext(ctx, "gh", "release", "view",
"-R", s.config.GitHubRepo, "-R", s.config.GitHubRepo,
"--json", "tagName", "--json", "tagName",
"-q", ".tagName", "-q", ".tagName",
@ -50,20 +54,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 strings.TrimSpace(string(out)), nil return core.Trim(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 := exec.CommandContext(ctx, "gh", "release", "download", cmd := proc.NewCommandContext(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 = os.Stdout cmd.Stdout = proc.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = proc.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)

View file

@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestGitHubSource_Good_Available(t *testing.T) { func TestGitHubSource_Available_Good(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_Good_Available(t *testing.T) {
_ = src.Available() _ = src.Available()
} }
func TestGitHubSource_Name(t *testing.T) { func TestGitHubSource_Name_Good(t *testing.T) {
src := NewGitHubSource(SourceConfig{}) src := NewGitHubSource(SourceConfig{})
assert.Equal(t, "github", src.Name()) assert.Equal(t, "github", src.Name())
} }
func TestGitHubSource_Config(t *testing.T) { func TestGitHubSource_Config_Good(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(t *testing.T) {
assert.Equal(t, "test-image.qcow2", src.config.ImageName) assert.Equal(t, "test-image.qcow2", src.config.ImageName)
} }
func TestGitHubSource_Good_Multiple(t *testing.T) { func TestGitHubSource_Multiple_Good(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_Good_Multiple(t *testing.T) {
assert.Equal(t, "github", src2.Name()) assert.Equal(t, "github", src2.Name())
} }
func TestNewGitHubSource_Good(t *testing.T) { func TestGitHub_NewGitHubSource_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 TestNewGitHubSource_Good(t *testing.T) {
assert.Equal(t, cfg.GitHubRepo, src.config.GitHubRepo) assert.Equal(t, cfg.GitHubRepo, src.config.GitHubRepo)
} }
func TestGitHubSource_InterfaceCompliance(t *testing.T) { func TestGitHubSource_InterfaceCompliance_Good(t *testing.T) {
// Verify GitHubSource implements ImageSource // Verify GitHubSource implements ImageSource
var _ ImageSource = (*GitHubSource)(nil) var _ ImageSource = (*GitHubSource)(nil)
} }

View file

@ -1,10 +1,10 @@
// Package sources provides image download sources for go-container. // Package sources provides image download sources for container.
package sources package sources
import ( import (
"context" "context"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/io"
) )
// ImageSource defines the interface for downloading dev images. // ImageSource defines the interface for downloading dev images.

View file

@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestSourceConfig_Empty(t *testing.T) { func TestSourceConfig_Empty_Good(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(t *testing.T) {
assert.Empty(t, cfg.ImageName) assert.Empty(t, cfg.ImageName)
} }
func TestSourceConfig_Complete(t *testing.T) { func TestSourceConfig_Complete_Good(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(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(t *testing.T) { func TestImageSource_Interface_Good(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)

View file

@ -1,12 +1,13 @@
package container package container
import ( import (
"encoding/json" "io/fs"
"os"
"path/filepath"
"sync" "sync"
"forge.lthn.ai/core/go-io" core "dappco.re/go/core"
"dappco.re/go/core/io"
"dappco.re/go/core/container/internal/coreutil"
) )
// State manages persistent container state. // State manages persistent container state.
@ -19,33 +20,49 @@ 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, err := os.UserHomeDir() home := coreutil.HomeDir()
if err != nil { if home == "" {
return "", err return "", core.E("DefaultStateDir", "home directory not available", nil)
} }
return filepath.Join(home, ".core"), nil return coreutil.JoinPath(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 filepath.Join(dir, "containers.json"), nil return coreutil.JoinPath(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 filepath.Join(dir, "logs"), nil return coreutil.JoinPath(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),
@ -55,19 +72,24 @@ 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 os.IsNotExist(err) { if core.Is(err, fs.ErrNotExist) {
return state, nil return state, nil
} }
return nil, err return nil, err
} }
if err := json.Unmarshal([]byte(dataStr), state); err != nil { result := core.JSONUnmarshalString(dataStr, state)
return nil, err if !result.OK {
return nil, result.Value.(error)
} }
return state, nil return state, nil
@ -79,17 +101,17 @@ func (s *State) SaveState() error {
defer s.mu.RUnlock() defer s.mu.RUnlock()
// Ensure the directory exists // Ensure the directory exists
dir := filepath.Dir(s.filePath) dir := core.PathDir(s.filePath)
if err := io.Local.EnsureDir(dir); err != nil { if err := io.Local.EnsureDir(dir); err != nil {
return err return err
} }
data, err := json.MarshalIndent(s, "", " ") result := core.JSONMarshal(s)
if err != nil { if !result.OK {
return err return result.Value.(error)
} }
return io.Local.Write(s.filePath, string(data)) return io.Local.Write(s.filePath, string(result.Value.([]byte)))
} }
// Add adds a container to the state and persists it. // Add adds a container to the state and persists it.
@ -154,15 +176,23 @@ 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 filepath.Join(logsDir, id+".log"), nil return coreutil.JoinPath(logsDir, core.Concat(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 {

View file

@ -1,16 +1,18 @@
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 TestNewState_Good(t *testing.T) { func TestState_NewState_Good(t *testing.T) {
state := NewState("/tmp/test-state.json") state := NewState("/tmp/test-state.json")
assert.NotNil(t, state) assert.NotNil(t, state)
@ -18,10 +20,10 @@ func TestNewState_Good(t *testing.T) {
assert.Equal(t, "/tmp/test-state.json", state.FilePath()) assert.Equal(t, "/tmp/test-state.json", state.FilePath())
} }
func TestLoadState_Good_NewFile(t *testing.T) { func TestLoadState_NewFile_Good(t *testing.T) {
// Test loading from non-existent file // Test loading from non-existent file
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(tmpDir, "containers.json")
state, err := LoadState(statePath) state, err := LoadState(statePath)
@ -30,9 +32,9 @@ func TestLoadState_Good_NewFile(t *testing.T) {
assert.Empty(t, state.Containers) assert.Empty(t, state.Containers)
} }
func TestLoadState_Good_ExistingFile(t *testing.T) { func TestLoadState_ExistingFile_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(tmpDir, "containers.json")
// Create a state file with data // Create a state file with data
content := `{ content := `{
@ -47,7 +49,7 @@ func TestLoadState_Good_ExistingFile(t *testing.T) {
} }
} }
}` }`
err := os.WriteFile(statePath, []byte(content), 0644) err := io.Local.Write(statePath, content)
require.NoError(t, err) require.NoError(t, err)
state, err := LoadState(statePath) state, err := LoadState(statePath)
@ -61,12 +63,12 @@ func TestLoadState_Good_ExistingFile(t *testing.T) {
assert.Equal(t, StatusRunning, c.Status) assert.Equal(t, StatusRunning, c.Status)
} }
func TestLoadState_Bad_InvalidJSON(t *testing.T) { func TestLoadState_InvalidJSON_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(tmpDir, "containers.json")
// Create invalid JSON // Create invalid JSON
err := os.WriteFile(statePath, []byte("invalid json{"), 0644) err := io.Local.Write(statePath, "invalid json{")
require.NoError(t, err) require.NoError(t, err)
_, err = LoadState(statePath) _, err = LoadState(statePath)
@ -75,7 +77,7 @@ func TestLoadState_Bad_InvalidJSON(t *testing.T) {
func TestState_Add_Good(t *testing.T) { func TestState_Add_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(tmpDir, "containers.json")
state := NewState(statePath) state := NewState(statePath)
container := &Container{ container := &Container{
@ -96,13 +98,12 @@ 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
_, err = os.Stat(statePath) assert.True(t, io.Local.IsFile(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 := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(tmpDir, "containers.json")
state := NewState(statePath) state := NewState(statePath)
container := &Container{ container := &Container{
@ -124,7 +125,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 := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(tmpDir, "containers.json")
state := NewState(statePath) state := NewState(statePath)
container := &Container{ container := &Container{
@ -139,7 +140,7 @@ func TestState_Remove_Good(t *testing.T) {
assert.False(t, ok) assert.False(t, ok)
} }
func TestState_Get_Bad_NotFound(t *testing.T) { func TestState_Get_NotFound_Bad(t *testing.T) {
state := NewState("/tmp/test-state.json") state := NewState("/tmp/test-state.json")
_, ok := state.Get("nonexistent") _, ok := state.Get("nonexistent")
@ -148,7 +149,7 @@ func TestState_Get_Bad_NotFound(t *testing.T) {
func TestState_All_Good(t *testing.T) { func TestState_All_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "containers.json") statePath := coreutil.JoinPath(tmpDir, "containers.json")
state := NewState(statePath) state := NewState(statePath)
_ = state.Add(&Container{ID: "aaa11111"}) _ = state.Add(&Container{ID: "aaa11111"})
@ -159,9 +160,9 @@ func TestState_All_Good(t *testing.T) {
assert.Len(t, all, 3) assert.Len(t, all, 3)
} }
func TestState_SaveState_Good_CreatesDirectory(t *testing.T) { func TestState_SaveState_CreatesDirectory_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
nestedPath := filepath.Join(tmpDir, "nested", "dir", "containers.json") nestedPath := coreutil.JoinPath(tmpDir, "nested", "dir", "containers.json")
state := NewState(nestedPath) state := NewState(nestedPath)
_ = state.Add(&Container{ID: "abc12345"}) _ = state.Add(&Container{ID: "abc12345"})
@ -170,45 +171,43 @@ func TestState_SaveState_Good_CreatesDirectory(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Verify directory was created // Verify directory was created
_, err = os.Stat(filepath.Dir(nestedPath)) assert.True(t, io.Local.IsDir(core.PathDir(nestedPath)))
assert.NoError(t, err)
} }
func TestDefaultStateDir_Good(t *testing.T) { func TestState_DefaultStateDir_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 TestDefaultStatePath_Good(t *testing.T) { func TestState_DefaultStatePath_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 TestDefaultLogsDir_Good(t *testing.T) { func TestState_DefaultLogsDir_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 TestLogPath_Good(t *testing.T) { func TestState_LogPath_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 TestEnsureLogsDir_Good(t *testing.T) { func TestState_EnsureLogsDir_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()
_, err = os.Stat(logsDir) assert.True(t, io.Local.IsDir(logsDir))
assert.NoError(t, err)
} }
func TestGenerateID_Good(t *testing.T) { func TestState_GenerateID_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)

View file

@ -4,14 +4,14 @@ import (
"embed" "embed"
"iter" "iter"
"maps" "maps"
"os"
"path/filepath"
"regexp" "regexp"
"slices" "slices"
"strings"
"forge.lthn.ai/core/go-io" core "dappco.re/go/core"
coreerr "forge.lthn.ai/core/go-log" "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/coreutil"
) )
//go:embed templates/*.yml //go:embed templates/*.yml
@ -44,11 +44,19 @@ 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
@ -72,6 +80,10 @@ 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 {
@ -87,7 +99,7 @@ func GetTemplate(name string) (string, error) {
// Check user templates // Check user templates
userTemplatesDir := getUserTemplatesDir() userTemplatesDir := getUserTemplatesDir()
if userTemplatesDir != "" { if userTemplatesDir != "" {
templatePath := filepath.Join(userTemplatesDir, name+".yml") templatePath := coreutil.JoinPath(userTemplatesDir, core.Concat(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 {
@ -104,6 +116,10 @@ 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 {
@ -117,6 +133,10 @@ 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_]*):-([^}]*)\}`)
@ -158,7 +178,7 @@ func ApplyVariables(content string, vars map[string]string) (string, error) {
}) })
if len(missingVars) > 0 { if len(missingVars) > 0 {
return "", coreerr.E("ApplyVariables", "missing required variables: "+strings.Join(missingVars, ", "), nil) return "", coreerr.E("ApplyVariables", core.Concat("missing required variables: ", core.Join(", ", missingVars...)), nil)
} }
return result, nil return result, nil
@ -166,6 +186,10 @@ 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)
@ -206,21 +230,18 @@ 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
cwd, err := os.Getwd() wsDir := coreutil.JoinPath(coreutil.CurrentDir(), ".core", "linuxkit")
if err == nil { if io.Local.IsDir(wsDir) {
wsDir := filepath.Join(cwd, ".core", "linuxkit") return wsDir
if io.Local.IsDir(wsDir) {
return wsDir
}
} }
// Try home directory // Try home directory
home, err := os.UserHomeDir() home := coreutil.HomeDir()
if err != nil { if home == "" {
return "" return ""
} }
homeDir := filepath.Join(home, ".core", "linuxkit") homeDir := coreutil.JoinPath(home, ".core", "linuxkit")
if io.Local.IsDir(homeDir) { if io.Local.IsDir(homeDir) {
return homeDir return homeDir
} }
@ -243,12 +264,12 @@ func scanUserTemplates(dir string) []Template {
} }
name := entry.Name() name := entry.Name()
if !strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml") { if !core.HasSuffix(name, ".yml") && !core.HasSuffix(name, ".yaml") {
continue continue
} }
// Extract template name from filename // Extract template name from filename
templateName := strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml") templateName := core.TrimSuffix(core.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
@ -263,7 +284,7 @@ func scanUserTemplates(dir string) []Template {
} }
// Read file to extract description from comments // Read file to extract description from comments
description := extractTemplateDescription(filepath.Join(dir, name)) description := extractTemplateDescription(coreutil.JoinPath(dir, name))
if description == "" { if description == "" {
description = "User-defined template" description = "User-defined template"
} }
@ -271,7 +292,7 @@ func scanUserTemplates(dir string) []Template {
templates = append(templates, Template{ templates = append(templates, Template{
Name: templateName, Name: templateName,
Description: description, Description: description,
Path: filepath.Join(dir, name), Path: coreutil.JoinPath(dir, name),
}) })
} }
@ -286,14 +307,14 @@ func extractTemplateDescription(path string) string {
return "" return ""
} }
lines := strings.Split(content, "\n") lines := core.Split(content, "\n")
var descLines []string var descLines []string
for _, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := core.Trim(line)
if strings.HasPrefix(trimmed, "#") { if core.HasPrefix(trimmed, "#") {
// Remove the # and trim // Remove the # and trim
comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) comment := core.Trim(core.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

View file

@ -1,16 +1,17 @@
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 TestListTemplates_Good(t *testing.T) { func TestTemplates_ListTemplates_Good(t *testing.T) {
templates := ListTemplates() templates := ListTemplates()
// Should have at least the builtin templates // Should have at least the builtin templates
@ -41,7 +42,7 @@ func TestListTemplates_Good(t *testing.T) {
assert.True(t, found, "server-php template should exist") assert.True(t, found, "server-php template should exist")
} }
func TestGetTemplate_Good_CoreDev(t *testing.T) { func TestGetTemplate_CoreDev_Good(t *testing.T) {
content, err := GetTemplate("core-dev") content, err := GetTemplate("core-dev")
require.NoError(t, err) require.NoError(t, err)
@ -52,7 +53,7 @@ func TestGetTemplate_Good_CoreDev(t *testing.T) {
assert.Contains(t, content, "services:") assert.Contains(t, content, "services:")
} }
func TestGetTemplate_Good_ServerPhp(t *testing.T) { func TestGetTemplate_ServerPhp_Good(t *testing.T) {
content, err := GetTemplate("server-php") content, err := GetTemplate("server-php")
require.NoError(t, err) require.NoError(t, err)
@ -63,14 +64,14 @@ func TestGetTemplate_Good_ServerPhp(t *testing.T) {
assert.Contains(t, content, "${DOMAIN:-localhost}") assert.Contains(t, content, "${DOMAIN:-localhost}")
} }
func TestGetTemplate_Bad_NotFound(t *testing.T) { func TestGetTemplate_NotFound_Bad(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_Good_SimpleSubstitution(t *testing.T) { func TestApplyVariables_SimpleSubstitution_Good(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",
@ -83,7 +84,7 @@ func TestApplyVariables_Good_SimpleSubstitution(t *testing.T) {
assert.Equal(t, "Hello World, welcome to Core!", result) assert.Equal(t, "Hello World, welcome to Core!", result)
} }
func TestApplyVariables_Good_WithDefaults(t *testing.T) { func TestApplyVariables_WithDefaults_Good(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",
@ -96,7 +97,7 @@ func TestApplyVariables_Good_WithDefaults(t *testing.T) {
assert.Equal(t, "Memory: 2048MB, CPUs: 2", result) assert.Equal(t, "Memory: 2048MB, CPUs: 2", result)
} }
func TestApplyVariables_Good_AllDefaults(t *testing.T) { func TestApplyVariables_AllDefaults_Good(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
@ -106,7 +107,7 @@ func TestApplyVariables_Good_AllDefaults(t *testing.T) {
assert.Equal(t, "localhost:8080", result) assert.Equal(t, "localhost:8080", result)
} }
func TestApplyVariables_Good_MixedSyntax(t *testing.T) { func TestApplyVariables_MixedSyntax_Good(t *testing.T) {
content := ` content := `
hostname: ${HOSTNAME:-myhost} hostname: ${HOSTNAME:-myhost}
ssh_key: ${SSH_KEY} ssh_key: ${SSH_KEY}
@ -125,7 +126,7 @@ memory: ${MEMORY:-512}
assert.Contains(t, result, "memory: 512") assert.Contains(t, result, "memory: 512")
} }
func TestApplyVariables_Good_EmptyDefault(t *testing.T) { func TestApplyVariables_EmptyDefault_Good(t *testing.T) {
content := "value: ${OPT:-}" content := "value: ${OPT:-}"
vars := map[string]string{} vars := map[string]string{}
@ -135,7 +136,7 @@ func TestApplyVariables_Good_EmptyDefault(t *testing.T) {
assert.Equal(t, "value: ", result) assert.Equal(t, "value: ", result)
} }
func TestApplyVariables_Bad_MissingRequired(t *testing.T) { func TestApplyVariables_MissingRequired_Bad(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
@ -146,7 +147,7 @@ func TestApplyVariables_Bad_MissingRequired(t *testing.T) {
assert.Contains(t, err.Error(), "SSH_KEY") assert.Contains(t, err.Error(), "SSH_KEY")
} }
func TestApplyVariables_Bad_MultipleMissing(t *testing.T) { func TestApplyVariables_MultipleMissing_Bad(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",
@ -158,10 +159,10 @@ func TestApplyVariables_Bad_MultipleMissing(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, strings.Contains(errStr, "VAR1") || strings.Contains(errStr, "VAR3")) assert.True(t, core.Contains(errStr, "VAR1") || core.Contains(errStr, "VAR3"))
} }
func TestApplyTemplate_Good(t *testing.T) { func TestTemplates_ApplyTemplate_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",
} }
@ -175,7 +176,7 @@ func TestApplyTemplate_Good(t *testing.T) {
assert.Contains(t, result, "core-dev") // HOSTNAME default assert.Contains(t, result, "core-dev") // HOSTNAME default
} }
func TestApplyTemplate_Bad_TemplateNotFound(t *testing.T) { func TestApplyTemplate_TemplateNotFound_Bad(t *testing.T) {
vars := map[string]string{ vars := map[string]string{
"SSH_KEY": "test", "SSH_KEY": "test",
} }
@ -186,7 +187,7 @@ func TestApplyTemplate_Bad_TemplateNotFound(t *testing.T) {
assert.Contains(t, err.Error(), "template not found") assert.Contains(t, err.Error(), "template not found")
} }
func TestApplyTemplate_Bad_MissingVariable(t *testing.T) { func TestApplyTemplate_MissingVariable_Bad(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
@ -196,7 +197,7 @@ func TestApplyTemplate_Bad_MissingVariable(t *testing.T) {
assert.Contains(t, err.Error(), "missing required variables") assert.Contains(t, err.Error(), "missing required variables")
} }
func TestExtractVariables_Good(t *testing.T) { func TestTemplates_ExtractVariables_Good(t *testing.T) {
content := ` content := `
hostname: ${HOSTNAME:-myhost} hostname: ${HOSTNAME:-myhost}
ssh_key: ${SSH_KEY} ssh_key: ${SSH_KEY}
@ -218,7 +219,7 @@ api_key: ${API_KEY}
assert.Len(t, optional, 3) assert.Len(t, optional, 3)
} }
func TestExtractVariables_Good_NoVariables(t *testing.T) { func TestExtractVariables_NoVariables_Good(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)
@ -227,7 +228,7 @@ func TestExtractVariables_Good_NoVariables(t *testing.T) {
assert.Empty(t, optional) assert.Empty(t, optional)
} }
func TestExtractVariables_Good_OnlyDefaults(t *testing.T) { func TestExtractVariables_OnlyDefaults_Good(t *testing.T) {
content := "${A:-default1} ${B:-default2}" content := "${A:-default1} ${B:-default2}"
required, optional := ExtractVariables(content) required, optional := ExtractVariables(content)
@ -238,7 +239,7 @@ func TestExtractVariables_Good_OnlyDefaults(t *testing.T) {
assert.Equal(t, "default2", optional["B"]) assert.Equal(t, "default2", optional["B"])
} }
func TestScanUserTemplates_Good(t *testing.T) { func TestTemplates_ScanUserTemplates_Good(t *testing.T) {
// Create a temporary directory with template files // Create a temporary directory with template files
tmpDir := t.TempDir() tmpDir := t.TempDir()
@ -248,11 +249,11 @@ func TestScanUserTemplates_Good(t *testing.T) {
kernel: kernel:
image: linuxkit/kernel:6.6 image: linuxkit/kernel:6.6
` `
err := os.WriteFile(filepath.Join(tmpDir, "custom.yml"), []byte(templateContent), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "custom.yml"), templateContent)
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 = os.WriteFile(filepath.Join(tmpDir, "readme.txt"), []byte("Not a template"), 0644) err = io.Local.Write(coreutil.JoinPath(tmpDir, "readme.txt"), "Not a template")
require.NoError(t, err) require.NoError(t, err)
templates := scanUserTemplates(tmpDir) templates := scanUserTemplates(tmpDir)
@ -262,13 +263,13 @@ kernel:
assert.Equal(t, "My Custom Template", templates[0].Description) assert.Equal(t, "My Custom Template", templates[0].Description)
} }
func TestScanUserTemplates_Good_MultipleTemplates(t *testing.T) { func TestScanUserTemplates_MultipleTemplates_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Create multiple template files // Create multiple template files
err := os.WriteFile(filepath.Join(tmpDir, "web.yml"), []byte("# Web Server\nkernel:"), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "web.yml"), "# Web Server\nkernel:")
require.NoError(t, err) require.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpDir, "db.yaml"), []byte("# Database Server\nkernel:"), 0644) err = io.Local.Write(coreutil.JoinPath(tmpDir, "db.yaml"), "# Database Server\nkernel:")
require.NoError(t, err) require.NoError(t, err)
templates := scanUserTemplates(tmpDir) templates := scanUserTemplates(tmpDir)
@ -284,7 +285,7 @@ func TestScanUserTemplates_Good_MultipleTemplates(t *testing.T) {
assert.True(t, names["db"]) assert.True(t, names["db"])
} }
func TestScanUserTemplates_Good_EmptyDirectory(t *testing.T) { func TestScanUserTemplates_EmptyDirectory_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
templates := scanUserTemplates(tmpDir) templates := scanUserTemplates(tmpDir)
@ -292,22 +293,22 @@ func TestScanUserTemplates_Good_EmptyDirectory(t *testing.T) {
assert.Empty(t, templates) assert.Empty(t, templates)
} }
func TestScanUserTemplates_Bad_NonexistentDirectory(t *testing.T) { func TestScanUserTemplates_NonexistentDirectory_Bad(t *testing.T) {
templates := scanUserTemplates("/nonexistent/path/to/templates") templates := scanUserTemplates("/nonexistent/path/to/templates")
assert.Empty(t, templates) assert.Empty(t, templates)
} }
func TestExtractTemplateDescription_Good(t *testing.T) { func TestTemplates_ExtractTemplateDescription_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.yml") path := coreutil.JoinPath(tmpDir, "test.yml")
content := `# My Template Description content := `# My Template Description
# More details here # More details here
kernel: kernel:
image: test image: test
` `
err := os.WriteFile(path, []byte(content), 0644) err := io.Local.Write(path, content)
require.NoError(t, err) require.NoError(t, err)
desc := extractTemplateDescription(path) desc := extractTemplateDescription(path)
@ -315,14 +316,14 @@ kernel:
assert.Equal(t, "My Template Description", desc) assert.Equal(t, "My Template Description", desc)
} }
func TestExtractTemplateDescription_Good_NoComments(t *testing.T) { func TestExtractTemplateDescription_NoComments_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.yml") path := coreutil.JoinPath(tmpDir, "test.yml")
content := `kernel: content := `kernel:
image: test image: test
` `
err := os.WriteFile(path, []byte(content), 0644) err := io.Local.Write(path, content)
require.NoError(t, err) require.NoError(t, err)
desc := extractTemplateDescription(path) desc := extractTemplateDescription(path)
@ -330,13 +331,13 @@ func TestExtractTemplateDescription_Good_NoComments(t *testing.T) {
assert.Empty(t, desc) assert.Empty(t, desc)
} }
func TestExtractTemplateDescription_Bad_FileNotFound(t *testing.T) { func TestExtractTemplateDescription_FileNotFound_Bad(t *testing.T) {
desc := extractTemplateDescription("/nonexistent/file.yml") desc := extractTemplateDescription("/nonexistent/file.yml")
assert.Empty(t, desc) assert.Empty(t, desc)
} }
func TestVariablePatternEdgeCases_Good(t *testing.T) { func TestTemplates_VariablePatternEdgeCases_Good(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
content string content string
@ -384,15 +385,15 @@ func TestVariablePatternEdgeCases_Good(t *testing.T) {
} }
} }
func TestScanUserTemplates_Good_SkipsBuiltinNames(t *testing.T) { func TestScanUserTemplates_SkipsBuiltinNames_Good(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 := os.WriteFile(filepath.Join(tmpDir, "core-dev.yml"), []byte("# Duplicate\nkernel:"), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "core-dev.yml"), "# Duplicate\nkernel:")
require.NoError(t, err) require.NoError(t, err)
// Create a unique template // Create a unique template
err = os.WriteFile(filepath.Join(tmpDir, "unique.yml"), []byte("# Unique\nkernel:"), 0644) err = io.Local.Write(coreutil.JoinPath(tmpDir, "unique.yml"), "# Unique\nkernel:")
require.NoError(t, err) require.NoError(t, err)
templates := scanUserTemplates(tmpDir) templates := scanUserTemplates(tmpDir)
@ -402,15 +403,15 @@ func TestScanUserTemplates_Good_SkipsBuiltinNames(t *testing.T) {
assert.Equal(t, "unique", templates[0].Name) assert.Equal(t, "unique", templates[0].Name)
} }
func TestScanUserTemplates_Good_SkipsDirectories(t *testing.T) { func TestScanUserTemplates_SkipsDirectories_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Create a subdirectory (should be skipped) // Create a subdirectory (should be skipped)
err := os.MkdirAll(filepath.Join(tmpDir, "subdir"), 0755) err := io.Local.EnsureDir(coreutil.JoinPath(tmpDir, "subdir"))
require.NoError(t, err) require.NoError(t, err)
// Create a valid template // Create a valid template
err = os.WriteFile(filepath.Join(tmpDir, "valid.yml"), []byte("# Valid\nkernel:"), 0644) err = io.Local.Write(coreutil.JoinPath(tmpDir, "valid.yml"), "# Valid\nkernel:")
require.NoError(t, err) require.NoError(t, err)
templates := scanUserTemplates(tmpDir) templates := scanUserTemplates(tmpDir)
@ -419,13 +420,13 @@ func TestScanUserTemplates_Good_SkipsDirectories(t *testing.T) {
assert.Equal(t, "valid", templates[0].Name) assert.Equal(t, "valid", templates[0].Name)
} }
func TestScanUserTemplates_Good_YamlExtension(t *testing.T) { func TestScanUserTemplates_YamlExtension_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Create templates with both extensions // Create templates with both extensions
err := os.WriteFile(filepath.Join(tmpDir, "template1.yml"), []byte("# Template 1\nkernel:"), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "template1.yml"), "# Template 1\nkernel:")
require.NoError(t, err) require.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpDir, "template2.yaml"), []byte("# Template 2\nkernel:"), 0644) err = io.Local.Write(coreutil.JoinPath(tmpDir, "template2.yaml"), "# Template 2\nkernel:")
require.NoError(t, err) require.NoError(t, err)
templates := scanUserTemplates(tmpDir) templates := scanUserTemplates(tmpDir)
@ -440,9 +441,9 @@ func TestScanUserTemplates_Good_YamlExtension(t *testing.T) {
assert.True(t, names["template2"]) assert.True(t, names["template2"])
} }
func TestExtractTemplateDescription_Good_EmptyComment(t *testing.T) { func TestExtractTemplateDescription_EmptyComment_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.yml") path := coreutil.JoinPath(tmpDir, "test.yml")
// First comment is empty, second has content // First comment is empty, second has content
content := `# content := `#
@ -450,7 +451,7 @@ func TestExtractTemplateDescription_Good_EmptyComment(t *testing.T) {
kernel: kernel:
image: test image: test
` `
err := os.WriteFile(path, []byte(content), 0644) err := io.Local.Write(path, content)
require.NoError(t, err) require.NoError(t, err)
desc := extractTemplateDescription(path) desc := extractTemplateDescription(path)
@ -458,9 +459,9 @@ kernel:
assert.Equal(t, "Actual description here", desc) assert.Equal(t, "Actual description here", desc)
} }
func TestExtractTemplateDescription_Good_MultipleEmptyComments(t *testing.T) { func TestExtractTemplateDescription_MultipleEmptyComments_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.yml") path := coreutil.JoinPath(tmpDir, "test.yml")
// Multiple empty comments before actual content // Multiple empty comments before actual content
content := `# content := `#
@ -470,7 +471,7 @@ func TestExtractTemplateDescription_Good_MultipleEmptyComments(t *testing.T) {
kernel: kernel:
image: test image: test
` `
err := os.WriteFile(path, []byte(content), 0644) err := io.Local.Write(path, content)
require.NoError(t, err) require.NoError(t, err)
desc := extractTemplateDescription(path) desc := extractTemplateDescription(path)
@ -478,14 +479,14 @@ kernel:
assert.Equal(t, "Real description", desc) assert.Equal(t, "Real description", desc)
} }
func TestScanUserTemplates_Good_DefaultDescription(t *testing.T) { func TestScanUserTemplates_DefaultDescription_Good(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 := os.WriteFile(filepath.Join(tmpDir, "nocomment.yml"), []byte(content), 0644) err := io.Local.Write(coreutil.JoinPath(tmpDir, "nocomment.yml"), content)
require.NoError(t, err) require.NoError(t, err)
templates := scanUserTemplates(tmpDir) templates := scanUserTemplates(tmpDir)