Compare commits

..

No commits in common. "dev" and "v0.1.4" have entirely different histories.
dev ... v0.1.4

44 changed files with 979 additions and 2015 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: `TestSubject_Function_{Good,Bad,Ugly}` - Tests use testify; naming convention: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (edge cases)
- Error wrapping: `core.E("Op", "message", err)` - Error wrapping: `fmt.Errorf("context: %w", err)`
- Context propagation: all blocking operations take `context.Context` as first parameter - Context propagation: all blocking operations take `context.Context` as first parameter
- Licence: EUPL-1.2 - Licence: EUPL-1.2

View file

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

View file

@ -1,436 +0,0 @@
# Upgrade Report: dappco.re/go/core v0.8.0-alpha.1
## Scope
- Repository: `core/go-container`
- Branch: `agent/create-an-upgrade-plan-for-this-package`
- Requested target: `dappco.re/go/core v0.8.0-alpha.1`
- Consumers called out for break-risk review: `core`, `go-devops`
## Baseline Verification
- `go build ./...`: passed
- `go vet ./...`: passed
- `go test ./... -count=1 -timeout 120s`: passed
- `go test -cover ./...`: passed (`container` 81.7%, `cmd/vm` 0.0%, `devenv` 53.3%, `sources` 72.7%)
- `go mod tidy`: not run because this task is report-only and should not introduce dependency churn
## 1. go.mod Upgrade Plan
- Current core version: `dappco.re/go/core v0.5.0` at `go.mod:16`
- Required bump: `dappco.re/go/core v0.5.0` -> `dappco.re/go/core v0.8.0-alpha.1` at `go.mod:16`
- Direct `dappco.re/go/core/*` dependencies that should be compatibility-checked in the same upgrade pass:
- `go.mod:6` `dappco.re/go/core/i18n v0.2.0`
- `go.mod:7` `dappco.re/go/core/io v0.2.0`
- `go.mod:8` `dappco.re/go/core/log v0.1.0`
- Legacy `forge.lthn.ai` modules still present in `go.mod`; these should be reviewed during the core bump because they may pin older transitive core APIs:
- `go.mod:9` `forge.lthn.ai/core/cli v0.3.7`
- `go.mod:10` `forge.lthn.ai/core/config v0.1.8`
- `go.mod:17` `forge.lthn.ai/core/go v0.3.3`
- `go.mod:18` `forge.lthn.ai/core/go-i18n v0.1.7`
- `go.mod:19` `forge.lthn.ai/core/go-inference v0.1.6`
- `go.mod:20` `forge.lthn.ai/core/go-io v0.1.7`
- `go.mod:21` `forge.lthn.ai/core/go-log v0.0.4`
## 2. Banned Stdlib Imports
Each group lists every import site and the required Core replacement.
### `os`
- Replacement: Replace with core.Env/core.Fs
- `cmd/vm/cmd_container.go:7`
- `cmd/vm/cmd_templates.go:6`
- `devenv/claude.go:6`
- `devenv/config.go:4`
- `devenv/config_test.go:4`
- `devenv/devops.go:7`
- `devenv/devops_test.go:5`
- `devenv/images.go:7`
- `devenv/images_test.go:5`
- `devenv/serve.go:6`
- `devenv/serve_test.go:4`
- `devenv/shell.go:6`
- `devenv/ssh_utils.go:6`
- `devenv/test_test.go:4`
- `hypervisor.go:6`
- `linuxkit.go:8`
- `linuxkit_test.go:5`
- `sources/cdn.go:8`
- `sources/cdn_test.go:8`
- `sources/github.go:5`
- `state.go:5`
- `state_test.go:4`
- `templates.go:7`
- `templates_test.go:4`
### `os/exec`
- Replacement: No direct replacement was provided in the task; requires a manual audit for the v0.8.0-alpha.1 command-exec path
- `cmd/vm/cmd_templates.go:7`
- `devenv/claude.go:7`
- `devenv/devops_test.go:6`
- `devenv/serve.go:7`
- `devenv/shell.go:7`
- `devenv/ssh_utils.go:7`
- `hypervisor.go:7`
- `linuxkit.go:9`
- `linuxkit_test.go:6`
- `sources/github.go:6`
### `encoding/json`
- Replacement: Replace with core.JSONMarshalString/JSONUnmarshalString
- `devenv/images.go:5`
- `devenv/test.go:5`
- `state.go:4`
### `fmt`
- Replacement: Replace with core.Sprintf/core.Concat/core.E
- `cmd/vm/cmd_container.go:5`
- `cmd/vm/cmd_templates.go:5`
- `devenv/claude.go:5`
- `devenv/devops.go:6`
- `devenv/images.go:6`
- `devenv/serve.go:5`
- `devenv/shell.go:5`
- `devenv/ssh_utils.go:5`
- `hypervisor.go:5`
- `linuxkit.go:6`
- `sources/cdn.go:5`
- `sources/cdn_test.go:5`
### `errors`
- Replacement: Replace with core.E/core.Is
- No occurrences found
### `strings`
- Replacement: Replace with core.Contains/core.HasPrefix/core.Split/core.Trim/core.Replace
- `cmd/vm/cmd_container.go:8`
- `cmd/vm/cmd_templates.go:9`
- `devenv/claude.go:9`
- `devenv/ssh_utils.go:9`
- `devenv/test.go:7`
- `hypervisor.go:10`
- `sources/github.go:7`
- `templates.go:11`
- `templates_test.go:6`
### `path/filepath`
- Replacement: Replace with core.JoinPath/core.PathBase/core.PathDir
- `cmd/vm/cmd_templates.go:8`
- `devenv/claude.go:8`
- `devenv/config.go:5`
- `devenv/config_test.go:5`
- `devenv/devops.go:8`
- `devenv/devops_test.go:7`
- `devenv/images.go:8`
- `devenv/images_test.go:6`
- `devenv/serve.go:8`
- `devenv/serve_test.go:5`
- `devenv/ssh_utils.go:8`
- `devenv/test.go:6`
- `devenv/test_test.go:5`
- `hypervisor.go:8`
- `linuxkit_test.go:7`
- `sources/cdn.go:9`
- `sources/cdn_test.go:9`
- `state.go:6`
- `state_test.go:5`
- `templates.go:8`
- `templates_test.go:5`
## 3. Tests Not Matching `TestFile_Function_{Good,Bad,Ugly}`
- Total mismatches found: 236
- `devenv/claude_test.go:9` `TestClaudeOptions_Default`
- `devenv/claude_test.go:16` `TestClaudeOptions_Custom`
- `devenv/claude_test.go:27` `TestFormatAuthList_Good_NoAuth`
- `devenv/claude_test.go:33` `TestFormatAuthList_Good_Default`
- `devenv/claude_test.go:39` `TestFormatAuthList_Good_CustomAuth`
- `devenv/claude_test.go:47` `TestFormatAuthList_Good_MultipleAuth`
- `devenv/claude_test.go:55` `TestFormatAuthList_Good_EmptyAuth`
- `devenv/config_test.go:13` `TestDefaultConfig`
- `devenv/config_test.go:20` `TestConfigPath`
- `devenv/config_test.go:26` `TestLoadConfig_Good`
- `devenv/config_test.go:65` `TestLoadConfig_Bad`
- `devenv/config_test.go:82` `TestConfig_Struct`
- `devenv/config_test.go:105` `TestDefaultConfig_Complete`
- `devenv/config_test.go:114` `TestLoadConfig_Good_PartialConfig`
- `devenv/config_test.go:139` `TestLoadConfig_Good_AllSourceTypes`
- `devenv/config_test.go:208` `TestImagesConfig_Struct`
- `devenv/config_test.go:217` `TestGitHubConfig_Struct`
- `devenv/config_test.go:222` `TestRegistryConfig_Struct`
- `devenv/config_test.go:227` `TestCDNConfig_Struct`
- `devenv/config_test.go:232` `TestLoadConfig_Bad_UnreadableFile`
- `devenv/devops_test.go:18` `TestImageName`
- `devenv/devops_test.go:26` `TestImagesDir`
- `devenv/devops_test.go:48` `TestImagePath`
- `devenv/devops_test.go:58` `TestDefaultBootOptions`
- `devenv/devops_test.go:66` `TestIsInstalled_Bad`
- `devenv/devops_test.go:78` `TestIsInstalled_Good`
- `devenv/devops_test.go:142` `TestDevOps_Status_Good_NotInstalled`
- `devenv/devops_test.go:168` `TestDevOps_Status_Good_NoContainer`
- `devenv/devops_test.go:232` `TestDevOps_IsRunning_Bad_NotRunning`
- `devenv/devops_test.go:255` `TestDevOps_IsRunning_Bad_ContainerStopped`
- `devenv/devops_test.go:323` `TestDevOps_findContainer_Bad_NotFound`
- `devenv/devops_test.go:346` `TestDevOps_Stop_Bad_NotFound`
- `devenv/devops_test.go:369` `TestBootOptions_Custom`
- `devenv/devops_test.go:382` `TestDevStatus_Struct`
- `devenv/devops_test.go:403` `TestDevOps_Boot_Bad_NotInstalled`
- `devenv/devops_test.go:426` `TestDevOps_Boot_Bad_AlreadyRunning`
- `devenv/devops_test.go:465` `TestDevOps_Status_Good_WithImageVersion`
- `devenv/devops_test.go:501` `TestDevOps_findContainer_Good_MultipleContainers`
- `devenv/devops_test.go:546` `TestDevOps_Status_Good_ContainerWithUptime`
- `devenv/devops_test.go:583` `TestDevOps_IsRunning_Bad_DifferentContainerName`
- `devenv/devops_test.go:618` `TestDevOps_Boot_Good_FreshFlag`
- `devenv/devops_test.go:668` `TestDevOps_Stop_Bad_ContainerNotRunning`
- `devenv/devops_test.go:703` `TestDevOps_Boot_Good_FreshWithNoExisting`
- `devenv/devops_test.go:741` `TestImageName_Format`
- `devenv/devops_test.go:750` `TestDevOps_Install_Delegates`
- `devenv/devops_test.go:768` `TestDevOps_CheckUpdate_Delegates`
- `devenv/devops_test.go:786` `TestDevOps_Boot_Good_Success`
- `devenv/devops_test.go:818` `TestDevOps_Config`
- `devenv/images_test.go:16` `TestImageManager_Good_IsInstalled`
- `devenv/images_test.go:36` `TestNewImageManager_Good`
- `devenv/images_test.go:66` `TestManifest_Save`
- `devenv/images_test.go:94` `TestLoadManifest_Bad`
- `devenv/images_test.go:106` `TestCheckUpdate_Bad`
- `devenv/images_test.go:121` `TestNewImageManager_Good_AutoSource`
- `devenv/images_test.go:134` `TestNewImageManager_Good_UnknownSourceFallsToAuto`
- `devenv/images_test.go:147` `TestLoadManifest_Good_Empty`
- `devenv/images_test.go:159` `TestLoadManifest_Good_ExistingData`
- `devenv/images_test.go:174` `TestImageInfo_Struct`
- `devenv/images_test.go:187` `TestManifest_Save_Good_CreatesDirs`
- `devenv/images_test.go:207` `TestManifest_Save_Good_Overwrite`
- `devenv/images_test.go:239` `TestImageManager_Install_Bad_NoSourceAvailable`
- `devenv/images_test.go:256` `TestNewImageManager_Good_CreatesDir`
- `devenv/images_test.go:295` `TestImageManager_Install_Good_WithMockSource`
- `devenv/images_test.go:323` `TestImageManager_Install_Bad_DownloadError`
- `devenv/images_test.go:345` `TestImageManager_Install_Bad_VersionError`
- `devenv/images_test.go:367` `TestImageManager_Install_Good_SkipsUnavailableSource`
- `devenv/images_test.go:396` `TestImageManager_CheckUpdate_Good_WithMockSource`
- `devenv/images_test.go:426` `TestImageManager_CheckUpdate_Good_NoUpdate`
- `devenv/images_test.go:456` `TestImageManager_CheckUpdate_Bad_NoSource`
- `devenv/images_test.go:483` `TestImageManager_CheckUpdate_Bad_VersionError`
- `devenv/images_test.go:511` `TestImageManager_Install_Bad_EmptySources`
- `devenv/images_test.go:527` `TestImageManager_Install_Bad_AllUnavailable`
- `devenv/images_test.go:546` `TestImageManager_CheckUpdate_Good_FirstSourceUnavailable`
- `devenv/images_test.go:573` `TestManifest_Struct`
- `devenv/serve_test.go:12` `TestDetectServeCommand_Good_Laravel`
- `devenv/serve_test.go:21` `TestDetectServeCommand_Good_NodeDev`
- `devenv/serve_test.go:31` `TestDetectServeCommand_Good_NodeStart`
- `devenv/serve_test.go:41` `TestDetectServeCommand_Good_PHP`
- `devenv/serve_test.go:50` `TestDetectServeCommand_Good_GoMain`
- `devenv/serve_test.go:61` `TestDetectServeCommand_Good_GoWithoutMain`
- `devenv/serve_test.go:71` `TestDetectServeCommand_Good_Django`
- `devenv/serve_test.go:80` `TestDetectServeCommand_Good_Fallback`
- `devenv/serve_test.go:87` `TestDetectServeCommand_Good_Priority`
- `devenv/serve_test.go:99` `TestServeOptions_Default`
- `devenv/serve_test.go:105` `TestServeOptions_Custom`
- `devenv/serve_test.go:114` `TestHasFile_Good`
- `devenv/serve_test.go:123` `TestHasFile_Bad`
- `devenv/serve_test.go:129` `TestHasFile_Bad_Directory`
- `devenv/shell_test.go:9` `TestShellOptions_Default`
- `devenv/shell_test.go:15` `TestShellOptions_Console`
- `devenv/shell_test.go:23` `TestShellOptions_Command`
- `devenv/shell_test.go:31` `TestShellOptions_ConsoleWithCommand`
- `devenv/shell_test.go:40` `TestShellOptions_EmptyCommand`
- `devenv/test_test.go:11` `TestDetectTestCommand_Good_ComposerJSON`
- `devenv/test_test.go:21` `TestDetectTestCommand_Good_PackageJSON`
- `devenv/test_test.go:31` `TestDetectTestCommand_Good_GoMod`
- `devenv/test_test.go:41` `TestDetectTestCommand_Good_CoreTestYaml`
- `devenv/test_test.go:53` `TestDetectTestCommand_Good_Pytest`
- `devenv/test_test.go:63` `TestDetectTestCommand_Good_Taskfile`
- `devenv/test_test.go:73` `TestDetectTestCommand_Bad_NoFiles`
- `devenv/test_test.go:82` `TestDetectTestCommand_Good_Priority`
- `devenv/test_test.go:96` `TestLoadTestConfig_Good`
- `devenv/test_test.go:135` `TestLoadTestConfig_Bad_NotFound`
- `devenv/test_test.go:144` `TestHasPackageScript_Good`
- `devenv/test_test.go:156` `TestHasPackageScript_Bad_MissingScript`
- `devenv/test_test.go:165` `TestHasComposerScript_Good`
- `devenv/test_test.go:174` `TestHasComposerScript_Bad_MissingScript`
- `devenv/test_test.go:183` `TestTestConfig_Struct`
- `devenv/test_test.go:204` `TestTestCommand_Struct`
- `devenv/test_test.go:217` `TestTestOptions_Struct`
- `devenv/test_test.go:230` `TestDetectTestCommand_Good_TaskfileYml`
- `devenv/test_test.go:240` `TestDetectTestCommand_Good_Pyproject`
- `devenv/test_test.go:250` `TestHasPackageScript_Bad_NoFile`
- `devenv/test_test.go:258` `TestHasPackageScript_Bad_InvalidJSON`
- `devenv/test_test.go:267` `TestHasPackageScript_Bad_NoScripts`
- `devenv/test_test.go:276` `TestHasComposerScript_Bad_NoFile`
- `devenv/test_test.go:284` `TestHasComposerScript_Bad_InvalidJSON`
- `devenv/test_test.go:293` `TestHasComposerScript_Bad_NoScripts`
- `devenv/test_test.go:302` `TestLoadTestConfig_Bad_InvalidYAML`
- `devenv/test_test.go:314` `TestLoadTestConfig_Good_MinimalConfig`
- `devenv/test_test.go:332` `TestDetectTestCommand_Good_ComposerWithoutScript`
- `devenv/test_test.go:344` `TestDetectTestCommand_Good_PackageJSONWithoutScript`
- `hypervisor_test.go:23` `TestQemuHypervisor_Available_Bad_InvalidBinary`
- `hypervisor_test.go:47` `TestHyperkitHypervisor_Available_Bad_NotDarwin`
- `hypervisor_test.go:59` `TestHyperkitHypervisor_Available_Bad_InvalidBinary`
- `hypervisor_test.go:69` `TestIsKVMAvailable_Good`
- `hypervisor_test.go:83` `TestDetectHypervisor_Good`
- `hypervisor_test.go:98` `TestGetHypervisor_Good_Qemu`
- `hypervisor_test.go:110` `TestGetHypervisor_Good_QemuUppercase`
- `hypervisor_test.go:122` `TestGetHypervisor_Good_Hyperkit`
- `hypervisor_test.go:140` `TestGetHypervisor_Bad_Unknown`
- `hypervisor_test.go:147` `TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes`
- `hypervisor_test.go:175` `TestQemuHypervisor_BuildCommand_Good_QCow2Format`
- `hypervisor_test.go:195` `TestQemuHypervisor_BuildCommand_Good_VMDKFormat`
- `hypervisor_test.go:215` `TestQemuHypervisor_BuildCommand_Good_RawFormat`
- `hypervisor_test.go:235` `TestHyperkitHypervisor_BuildCommand_Good_WithPorts`
- `hypervisor_test.go:258` `TestHyperkitHypervisor_BuildCommand_Good_QCow2Format`
- `hypervisor_test.go:269` `TestHyperkitHypervisor_BuildCommand_Good_RawFormat`
- `hypervisor_test.go:280` `TestHyperkitHypervisor_BuildCommand_Good_NoPorts`
- `hypervisor_test.go:296` `TestQemuHypervisor_BuildCommand_Good_NoSSHPort`
- `hypervisor_test.go:312` `TestQemuHypervisor_BuildCommand_Bad_UnknownFormat`
- `hypervisor_test.go:323` `TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat`
- `hypervisor_test.go:339` `TestHyperkitHypervisor_BuildCommand_Good_ISOFormat`
- `linuxkit_test.go:76` `TestNewLinuxKitManagerWithHypervisor_Good`
- `linuxkit_test.go:89` `TestLinuxKitManager_Run_Good_Detached`
- `linuxkit_test.go:128` `TestLinuxKitManager_Run_Good_DefaultValues`
- `linuxkit_test.go:153` `TestLinuxKitManager_Run_Bad_ImageNotFound`
- `linuxkit_test.go:164` `TestLinuxKitManager_Run_Bad_UnsupportedFormat`
- `linuxkit_test.go:204` `TestLinuxKitManager_Stop_Bad_NotFound`
- `linuxkit_test.go:214` `TestLinuxKitManager_Stop_Bad_NotRunning`
- `linuxkit_test.go:251` `TestLinuxKitManager_List_Good_VerifiesRunningStatus`
- `linuxkit_test.go:303` `TestLinuxKitManager_Logs_Bad_NotFound`
- `linuxkit_test.go:313` `TestLinuxKitManager_Logs_Bad_NoLogFile`
- `linuxkit_test.go:336` `TestLinuxKitManager_Exec_Bad_NotFound`
- `linuxkit_test.go:346` `TestLinuxKitManager_Exec_Bad_NotRunning`
- `linuxkit_test.go:359` `TestDetectImageFormat_Good`
- `linuxkit_test.go:381` `TestDetectImageFormat_Bad_Unknown`
- `linuxkit_test.go:429` `TestLinuxKitManager_Logs_Good_Follow`
- `linuxkit_test.go:467` `TestFollowReader_Read_Good_WithData`
- `linuxkit_test.go:500` `TestFollowReader_Read_Good_ContextCancel`
- `linuxkit_test.go:544` `TestNewFollowReader_Bad_FileNotFound`
- `linuxkit_test.go:551` `TestLinuxKitManager_Run_Bad_BuildCommandError`
- `linuxkit_test.go:570` `TestLinuxKitManager_Run_Good_Foreground`
- `linuxkit_test.go:598` `TestLinuxKitManager_Stop_Good_ContextCancelled`
- `linuxkit_test.go:635` `TestIsProcessRunning_Good_ExistingProcess`
- `linuxkit_test.go:641` `TestIsProcessRunning_Bad_NonexistentProcess`
- `linuxkit_test.go:647` `TestLinuxKitManager_Run_Good_WithPortsAndVolumes`
- `linuxkit_test.go:676` `TestFollowReader_Read_Bad_ReaderError`
- `linuxkit_test.go:697` `TestLinuxKitManager_Run_Bad_StartError`
- `linuxkit_test.go:718` `TestLinuxKitManager_Run_Bad_ForegroundStartError`
- `linuxkit_test.go:739` `TestLinuxKitManager_Run_Good_ForegroundWithError`
- `linuxkit_test.go:762` `TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning`
- `sources/cdn_test.go:16` `TestCDNSource_Good_Available`
- `sources/cdn_test.go:26` `TestCDNSource_Bad_NoURL`
- `sources/cdn_test.go:118` `TestCDNSource_LatestVersion_Bad_NoManifest`
- `sources/cdn_test.go:134` `TestCDNSource_LatestVersion_Bad_ServerError`
- `sources/cdn_test.go:150` `TestCDNSource_Download_Good_NoProgress`
- `sources/cdn_test.go:174` `TestCDNSource_Download_Good_LargeFile`
- `sources/cdn_test.go:206` `TestCDNSource_Download_Bad_HTTPErrorCodes`
- `sources/cdn_test.go:238` `TestCDNSource_InterfaceCompliance`
- `sources/cdn_test.go:243` `TestCDNSource_Config`
- `sources/cdn_test.go:254` `TestNewCDNSource_Good`
- `sources/cdn_test.go:268` `TestCDNSource_Download_Good_CreatesDestDir`
- `sources/cdn_test.go:294` `TestSourceConfig_Struct`
- `sources/github_test.go:9` `TestGitHubSource_Good_Available`
- `sources/github_test.go:23` `TestGitHubSource_Name`
- `sources/github_test.go:28` `TestGitHubSource_Config`
- `sources/github_test.go:40` `TestGitHubSource_Good_Multiple`
- `sources/github_test.go:51` `TestNewGitHubSource_Good`
- `sources/github_test.go:65` `TestGitHubSource_InterfaceCompliance`
- `sources/source_test.go:9` `TestSourceConfig_Empty`
- `sources/source_test.go:17` `TestSourceConfig_Complete`
- `sources/source_test.go:31` `TestImageSource_Interface`
- `state_test.go:13` `TestNewState_Good`
- `state_test.go:21` `TestLoadState_Good_NewFile`
- `state_test.go:33` `TestLoadState_Good_ExistingFile`
- `state_test.go:64` `TestLoadState_Bad_InvalidJSON`
- `state_test.go:142` `TestState_Get_Bad_NotFound`
- `state_test.go:162` `TestState_SaveState_Good_CreatesDirectory`
- `state_test.go:177` `TestDefaultStateDir_Good`
- `state_test.go:183` `TestDefaultStatePath_Good`
- `state_test.go:189` `TestDefaultLogsDir_Good`
- `state_test.go:195` `TestLogPath_Good`
- `state_test.go:201` `TestEnsureLogsDir_Good`
- `state_test.go:211` `TestGenerateID_Good`
- `templates_test.go:13` `TestListTemplates_Good`
- `templates_test.go:44` `TestGetTemplate_Good_CoreDev`
- `templates_test.go:55` `TestGetTemplate_Good_ServerPhp`
- `templates_test.go:66` `TestGetTemplate_Bad_NotFound`
- `templates_test.go:73` `TestApplyVariables_Good_SimpleSubstitution`
- `templates_test.go:86` `TestApplyVariables_Good_WithDefaults`
- `templates_test.go:99` `TestApplyVariables_Good_AllDefaults`
- `templates_test.go:109` `TestApplyVariables_Good_MixedSyntax`
- `templates_test.go:128` `TestApplyVariables_Good_EmptyDefault`
- `templates_test.go:138` `TestApplyVariables_Bad_MissingRequired`
- `templates_test.go:149` `TestApplyVariables_Bad_MultipleMissing`
- `templates_test.go:164` `TestApplyTemplate_Good`
- `templates_test.go:178` `TestApplyTemplate_Bad_TemplateNotFound`
- `templates_test.go:189` `TestApplyTemplate_Bad_MissingVariable`
- `templates_test.go:199` `TestExtractVariables_Good`
- `templates_test.go:221` `TestExtractVariables_Good_NoVariables`
- `templates_test.go:230` `TestExtractVariables_Good_OnlyDefaults`
- `templates_test.go:241` `TestScanUserTemplates_Good`
- `templates_test.go:265` `TestScanUserTemplates_Good_MultipleTemplates`
- `templates_test.go:287` `TestScanUserTemplates_Good_EmptyDirectory`
- `templates_test.go:295` `TestScanUserTemplates_Bad_NonexistentDirectory`
- `templates_test.go:301` `TestExtractTemplateDescription_Good`
- `templates_test.go:318` `TestExtractTemplateDescription_Good_NoComments`
- `templates_test.go:333` `TestExtractTemplateDescription_Bad_FileNotFound`
- `templates_test.go:339` `TestVariablePatternEdgeCases_Good`
- `templates_test.go:387` `TestScanUserTemplates_Good_SkipsBuiltinNames`
- `templates_test.go:405` `TestScanUserTemplates_Good_SkipsDirectories`
- `templates_test.go:422` `TestScanUserTemplates_Good_YamlExtension`
- `templates_test.go:443` `TestExtractTemplateDescription_Good_EmptyComment`
- `templates_test.go:461` `TestExtractTemplateDescription_Good_MultipleEmptyComments`
- `templates_test.go:481` `TestScanUserTemplates_Good_DefaultDescription`
## 4. Exported Functions Missing Usage-Example Doc Comments
- Total exported functions missing a usage example marker: 38
- Note: every function listed below has a doc comment, but none of the comments include an obvious usage example marker such as `Usage:` or `Example:`.
- `cmd/vm/cmd_templates.go:150` `RunFromTemplate` (missing usage example)
- `cmd/vm/cmd_templates.go:296` `ParseVarFlags` (missing usage example)
- `cmd/vm/cmd_vm.go:28` `AddVMCommands` (missing usage example)
- `container.go:84` `GenerateID` (missing usage example)
- `devenv/config.go:41` `DefaultConfig` (missing usage example)
- `devenv/config.go:57` `ConfigPath` (missing usage example)
- `devenv/config.go:67` `LoadConfig` (missing usage example)
- `devenv/devops.go:31` `New` (missing usage example)
- `devenv/devops.go:56` `ImageName` (missing usage example)
- `devenv/devops.go:61` `ImagesDir` (missing usage example)
- `devenv/devops.go:73` `ImagePath` (missing usage example)
- `devenv/devops.go:109` `DefaultBootOptions` (missing usage example)
- `devenv/images.go:40` `NewImageManager` (missing usage example)
- `devenv/serve.go:75` `DetectServeCommand` (missing usage example)
- `devenv/test.go:75` `DetectTestCommand` (missing usage example)
- `devenv/test.go:115` `LoadTestConfig` (missing usage example)
- `hypervisor.go:50` `NewQemuHypervisor` (missing usage example)
- `hypervisor.go:155` `NewHyperkitHypervisor` (missing usage example)
- `hypervisor.go:222` `DetectImageFormat` (missing usage example)
- `hypervisor.go:239` `DetectHypervisor` (missing usage example)
- `hypervisor.go:258` `GetHypervisor` (missing usage example)
- `linuxkit.go:25` `NewLinuxKitManager` (missing usage example)
- `linuxkit.go:49` `NewLinuxKitManagerWithHypervisor` (missing usage example)
- `sources/cdn.go:24` `NewCDNSource` (missing usage example)
- `sources/github.go:22` `NewGitHubSource` (missing usage example)
- `state.go:22` `DefaultStateDir` (missing usage example)
- `state.go:31` `DefaultStatePath` (missing usage example)
- `state.go:40` `DefaultLogsDir` (missing usage example)
- `state.go:49` `NewState` (missing usage example)
- `state.go:58` `LoadState` (missing usage example)
- `state.go:157` `LogPath` (missing usage example)
- `state.go:166` `EnsureLogsDir` (missing usage example)
- `templates.go:47` `ListTemplates` (missing usage example)
- `templates.go:52` `ListTemplatesIter` (missing usage example)
- `templates.go:75` `GetTemplate` (missing usage example)
- `templates.go:107` `ApplyTemplate` (missing usage example)
- `templates.go:120` `ApplyVariables` (missing usage example)
- `templates.go:169` `ExtractVariables` (missing usage example)
## Risk Notes
- Breaking-change surface is moderate because this repo is consumed by two modules: `core` and `go-devops`.
- The highest-effort part of the upgrade is not the version bump itself; it is the repo-wide removal of banned stdlib imports, especially the current `os/exec` usage across runtime code and tests.
- The doc-comment and test-renaming work is mechanically simple, but it touches many files and will create broad diff surface for downstream review.

View file

@ -2,17 +2,18 @@ package vm
import ( import (
"context" "context"
"fmt"
goio "io" goio "io"
"os"
"strings"
"text/tabwriter" "text/tabwriter"
"time" "time"
core "dappco.re/go/core"
"dappco.re/go/core/container"
"dappco.re/go/core/container/internal/proc"
"dappco.re/go/core/i18n"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-container"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
) )
var ( var (
@ -81,12 +82,12 @@ func runContainer(image, name string, detach bool, memory, cpus, sshPort int) er
SSHPort: sshPort, SSHPort: sshPort,
} }
core.Print(nil, "%s %s", dimStyle.Render(i18n.Label("image")), image) fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("image")), image)
if name != "" { if name != "" {
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.name")), name) fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.name")), name)
} }
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name()) fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
core.Println() fmt.Println()
ctx := context.Background() ctx := context.Background()
c, err := manager.Run(ctx, image, opts) c, err := manager.Run(ctx, image, opts)
@ -95,14 +96,13 @@ func runContainer(image, name string, detach bool, memory, cpus, sshPort int) er
} }
if detach { if detach {
core.Print(nil, "%s %s", successStyle.Render(i18n.Label("started")), c.ID) fmt.Printf("%s %s\n", successStyle.Render(i18n.Label("started")), c.ID)
core.Print(nil, "%s %d", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID) fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
core.Println() fmt.Println()
core.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]})) fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]}))
core.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]})) fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]}))
} else { } else {
core.Println() fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
} }
return nil return nil
@ -151,16 +151,16 @@ func listContainers(all bool) error {
if len(containers) == 0 { if len(containers) == 0 {
if all { if all {
core.Println(i18n.T("cmd.vm.ps.no_containers")) fmt.Println(i18n.T("cmd.vm.ps.no_containers"))
} else { } else {
core.Println(i18n.T("cmd.vm.ps.no_running")) fmt.Println(i18n.T("cmd.vm.ps.no_running"))
} }
return nil return nil
} }
w := tabwriter.NewWriter(proc.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
core.Print(w, "%s", i18n.T("cmd.vm.ps.header")) _, _ = fmt.Fprintln(w, i18n.T("cmd.vm.ps.header"))
core.Print(w, "%s", "--\t----\t-----\t------\t-------\t---") _, _ = fmt.Fprintln(w, "--\t----\t-----\t------\t-------\t---")
for _, c := range containers { for _, c := range containers {
// Shorten image path // Shorten image path
@ -183,7 +183,7 @@ func listContainers(all bool) error {
status = errorStyle.Render(status) status = errorStyle.Render(status)
} }
core.Print(w, "%s\t%s\t%s\t%s\t%s\t%d", _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n",
c.ID[:8], c.Name, imageName, status, duration, c.PID) c.ID[:8], c.Name, imageName, status, duration, c.PID)
} }
@ -193,15 +193,15 @@ func listContainers(all bool) error {
func formatDuration(d time.Duration) string { func formatDuration(d time.Duration) string {
if d < time.Minute { if d < time.Minute {
return core.Sprintf("%ds", int(d.Seconds())) return fmt.Sprintf("%ds", int(d.Seconds()))
} }
if d < time.Hour { if d < time.Hour {
return core.Sprintf("%dm", int(d.Minutes())) return fmt.Sprintf("%dm", int(d.Minutes()))
} }
if d < 24*time.Hour { if d < 24*time.Hour {
return core.Sprintf("%dh", int(d.Hours())) return fmt.Sprintf("%dh", int(d.Hours()))
} }
return core.Sprintf("%dd", int(d.Hours()/24)) return fmt.Sprintf("%dd", int(d.Hours()/24))
} }
// addVMStopCommand adds the 'stop' command under vm. // addVMStopCommand adds the 'stop' command under vm.
@ -233,14 +233,14 @@ func stopContainer(id string) error {
return err return err
} }
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.stop.stopping")), fullID[:8]) fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.stop.stopping")), fullID[:8])
ctx := context.Background() ctx := context.Background()
if err := manager.Stop(ctx, fullID); err != nil { if err := manager.Stop(ctx, fullID); err != nil {
return coreerr.E("stopContainer", i18n.T("i18n.fail.stop", "container"), err) return coreerr.E("stopContainer", i18n.T("i18n.fail.stop", "container"), err)
} }
core.Print(nil, "%s", successStyle.Render(i18n.T("common.status.stopped"))) fmt.Printf("%s\n", successStyle.Render(i18n.T("common.status.stopped")))
return nil return nil
} }
@ -254,7 +254,7 @@ func resolveContainerID(manager *container.LinuxKitManager, partialID string) (s
var matches []*container.Container var matches []*container.Container
for _, c := range containers { for _, c := range containers {
if core.HasPrefix(c.ID, partialID) || core.HasPrefix(c.Name, partialID) { if strings.HasPrefix(c.ID, partialID) || strings.HasPrefix(c.Name, partialID) {
matches = append(matches, c) matches = append(matches, c)
} }
} }
@ -308,7 +308,7 @@ func viewLogs(id string, follow bool) error {
} }
defer func() { _ = reader.Close() }() defer func() { _ = reader.Close() }()
_, err = goio.Copy(proc.Stdout, reader) _, err = goio.Copy(os.Stdout, reader)
return err return err
} }

View file

@ -2,16 +2,18 @@ package vm
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/tabwriter" "text/tabwriter"
core "dappco.re/go/core"
"dappco.re/go/core/container"
"dappco.re/go/core/container/internal/coreutil"
"dappco.re/go/core/container/internal/proc"
"dappco.re/go/core/i18n"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-container"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
) )
// addVMTemplatesCommand adds the 'templates' command under vm. // addVMTemplatesCommand adds the 'templates' command under vm.
@ -70,30 +72,29 @@ func listTemplates() error {
templates := container.ListTemplates() templates := container.ListTemplates()
if len(templates) == 0 { if len(templates) == 0 {
core.Println(i18n.T("cmd.vm.templates.no_templates")) fmt.Println(i18n.T("cmd.vm.templates.no_templates"))
return nil return nil
} }
core.Print(nil, "%s", repoNameStyle.Render(i18n.T("cmd.vm.templates.title"))) fmt.Printf("%s\n\n", repoNameStyle.Render(i18n.T("cmd.vm.templates.title")))
core.Println()
w := tabwriter.NewWriter(proc.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
core.Print(w, "%s", i18n.T("cmd.vm.templates.header")) _, _ = fmt.Fprintln(w, i18n.T("cmd.vm.templates.header"))
core.Print(w, "%s", "----\t-----------") _, _ = fmt.Fprintln(w, "----\t-----------")
for _, tmpl := range templates { for _, tmpl := range templates {
desc := tmpl.Description desc := tmpl.Description
if len(desc) > 60 { if len(desc) > 60 {
desc = desc[:57] + "..." desc = desc[:57] + "..."
} }
core.Print(w, "%s\t%s", repoNameStyle.Render(tmpl.Name), desc) _, _ = fmt.Fprintf(w, "%s\t%s\n", repoNameStyle.Render(tmpl.Name), desc)
} }
_ = w.Flush() _ = w.Flush()
core.Println() fmt.Println()
core.Print(nil, "%s %s", i18n.T("cmd.vm.templates.hint.show"), dimStyle.Render("core vm templates show <name>")) fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.show"), dimStyle.Render("core vm templates show <name>"))
core.Print(nil, "%s %s", i18n.T("cmd.vm.templates.hint.vars"), dimStyle.Render("core vm templates vars <name>")) fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.vars"), dimStyle.Render("core vm templates vars <name>"))
core.Print(nil, "%s %s", i18n.T("cmd.vm.templates.hint.run"), dimStyle.Render("core vm run --template <name> --var SSH_KEY=\"...\"")) fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.run"), dimStyle.Render("core vm run --template <name> --var SSH_KEY=\"...\""))
return nil return nil
} }
@ -104,9 +105,8 @@ func showTemplate(name string) error {
return err return err
} }
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name)) fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name))
core.Println() fmt.Println(content)
core.Println(content)
return nil return nil
} }
@ -119,39 +119,34 @@ func showTemplateVars(name string) error {
required, optional := container.ExtractVariables(content) required, optional := container.ExtractVariables(content)
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name)) fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name))
core.Println()
if len(required) > 0 { if len(required) > 0 {
core.Print(nil, "%s", errorStyle.Render(i18n.T("cmd.vm.templates.vars.required"))) fmt.Printf("%s\n", errorStyle.Render(i18n.T("cmd.vm.templates.vars.required")))
for _, v := range required { for _, v := range required {
core.Print(nil, " %s", varStyle.Render("${"+v+"}")) fmt.Printf(" %s\n", varStyle.Render("${"+v+"}"))
} }
core.Println() fmt.Println()
} }
if len(optional) > 0 { if len(optional) > 0 {
core.Print(nil, "%s", successStyle.Render(i18n.T("cmd.vm.templates.vars.optional"))) fmt.Printf("%s\n", successStyle.Render(i18n.T("cmd.vm.templates.vars.optional")))
for v, def := range optional { for v, def := range optional {
core.Print(nil, " %s = %s", fmt.Printf(" %s = %s\n",
varStyle.Render("${"+v+"}"), varStyle.Render("${"+v+"}"),
defaultStyle.Render(def)) defaultStyle.Render(def))
} }
core.Println() fmt.Println()
} }
if len(required) == 0 && len(optional) == 0 { if len(required) == 0 && len(optional) == 0 {
core.Println(i18n.T("cmd.vm.templates.vars.none")) fmt.Println(i18n.T("cmd.vm.templates.vars.none"))
} }
return nil return nil
} }
// RunFromTemplate builds and runs a LinuxKit image from a template. // RunFromTemplate builds and runs a LinuxKit image from a template.
//
// Usage:
//
// err := RunFromTemplate("core-dev", vars, runOpts)
func RunFromTemplate(templateName string, vars map[string]string, runOpts container.RunOptions) error { func RunFromTemplate(templateName string, vars map[string]string, runOpts container.RunOptions) error {
// Apply template with variables // Apply template with variables
content, err := container.ApplyTemplate(templateName, vars) content, err := container.ApplyTemplate(templateName, vars)
@ -160,23 +155,23 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai
} }
// Create a temporary directory for the build // Create a temporary directory for the build
tmpDir, err := coreutil.MkdirTemp("core-linuxkit-") tmpDir, err := os.MkdirTemp("", "core-linuxkit-*")
if err != nil { if err != nil {
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "create temp directory"}), err) return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "create temp directory"}), err)
} }
defer func() { _ = io.Local.DeleteAll(tmpDir) }() defer func() { _ = os.RemoveAll(tmpDir) }()
// Write the YAML file // Write the YAML file
yamlPath := coreutil.JoinPath(tmpDir, core.Concat(templateName, ".yml")) yamlPath := filepath.Join(tmpDir, templateName+".yml")
if err := io.Local.Write(yamlPath, content); err != nil { if err := io.Local.Write(yamlPath, content); err != nil {
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "write template"}), err) return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "write template"}), err)
} }
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(templateName)) fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(templateName))
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.building")), yamlPath) fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.building")), yamlPath)
// Build the image using linuxkit // Build the image using linuxkit
outputPath := coreutil.JoinPath(tmpDir, templateName) outputPath := filepath.Join(tmpDir, templateName)
if err := buildLinuxKitImage(yamlPath, outputPath); err != nil { if err := buildLinuxKitImage(yamlPath, outputPath); err != nil {
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "build image"}), err) return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "build image"}), err)
} }
@ -187,8 +182,8 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai
return coreerr.E("RunFromTemplate", i18n.T("cmd.vm.error.no_image_found"), nil) return coreerr.E("RunFromTemplate", i18n.T("cmd.vm.error.no_image_found"), nil)
} }
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.image")), imagePath) fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("common.label.image")), imagePath)
core.Println() fmt.Println()
// Run the image // Run the image
manager, err := container.NewLinuxKitManager(io.Local) manager, err := container.NewLinuxKitManager(io.Local)
@ -196,8 +191,8 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai
return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "initialize container manager"}), err) return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "initialize container manager"}), err)
} }
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name()) fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
core.Println() fmt.Println()
ctx := context.Background() ctx := context.Background()
c, err := manager.Run(ctx, imagePath, runOpts) c, err := manager.Run(ctx, imagePath, runOpts)
@ -206,14 +201,13 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai
} }
if runOpts.Detach { if runOpts.Detach {
core.Print(nil, "%s %s", successStyle.Render(i18n.T("common.label.started")), c.ID) fmt.Printf("%s %s\n", successStyle.Render(i18n.T("common.label.started")), c.ID)
core.Print(nil, "%s %d", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID) fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
core.Println() fmt.Println()
core.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]})) fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]}))
core.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]})) fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]}))
} else { } else {
core.Println() fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
} }
return nil return nil
@ -229,13 +223,13 @@ func buildLinuxKitImage(yamlPath, outputPath string) error {
// Build the image // Build the image
// linuxkit build --format iso-bios --name <output> <yaml> // linuxkit build --format iso-bios --name <output> <yaml>
cmd := proc.NewCommand(lkPath, "build", cmd := exec.Command(lkPath, "build",
"--format", "iso-bios", "--format", "iso-bios",
"--name", outputPath, "--name", outputPath,
yamlPath) yamlPath)
cmd.Stdout = proc.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = proc.Stderr cmd.Stderr = os.Stderr
return cmd.Run() return cmd.Run()
} }
@ -246,27 +240,27 @@ func findBuiltImage(basePath string) string {
extensions := []string{".iso", "-bios.iso", ".qcow2", ".raw", ".vmdk"} extensions := []string{".iso", "-bios.iso", ".qcow2", ".raw", ".vmdk"}
for _, ext := range extensions { for _, ext := range extensions {
path := core.Concat(basePath, ext) path := basePath + ext
if io.Local.IsFile(path) { if _, err := os.Stat(path); err == nil {
return path return path
} }
} }
// Check directory for any image file // Check directory for any image file
dir := core.PathDir(basePath) dir := filepath.Dir(basePath)
base := core.PathBase(basePath) base := filepath.Base(basePath)
entries, err := io.Local.List(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
return "" return ""
} }
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
if core.HasPrefix(name, base) { if strings.HasPrefix(name, base) {
for _, ext := range []string{".iso", ".qcow2", ".raw", ".vmdk"} { for _, ext := range []string{".iso", ".qcow2", ".raw", ".vmdk"} {
if core.HasSuffix(name, ext) { if strings.HasSuffix(name, ext) {
return coreutil.JoinPath(dir, name) return filepath.Join(dir, name)
} }
} }
} }
@ -278,7 +272,7 @@ func findBuiltImage(basePath string) string {
// lookupLinuxKit finds the linuxkit binary. // lookupLinuxKit finds the linuxkit binary.
func lookupLinuxKit() (string, error) { func lookupLinuxKit() (string, error) {
// Check PATH first // Check PATH first
if path, err := proc.LookPath("linuxkit"); err == nil { if path, err := exec.LookPath("linuxkit"); err == nil {
return path, nil return path, nil
} }
@ -289,7 +283,7 @@ func lookupLinuxKit() (string, error) {
} }
for _, p := range paths { for _, p := range paths {
if io.Local.Exists(p) { if _, err := os.Stat(p); err == nil {
return p, nil return p, nil
} }
} }
@ -299,36 +293,19 @@ func lookupLinuxKit() (string, error) {
// ParseVarFlags parses --var flags into a map. // ParseVarFlags parses --var flags into a map.
// Format: --var KEY=VALUE or --var KEY="VALUE" // Format: --var KEY=VALUE or --var KEY="VALUE"
//
// Usage:
//
// vars := ParseVarFlags([]string{"SSH_KEY=abc", "PORT=2222"})
func ParseVarFlags(varFlags []string) map[string]string { func ParseVarFlags(varFlags []string) map[string]string {
vars := make(map[string]string) vars := make(map[string]string)
for _, v := range varFlags { for _, v := range varFlags {
parts := core.SplitN(v, "=", 2) parts := strings.SplitN(v, "=", 2)
if len(parts) == 2 { if len(parts) == 2 {
key := core.Trim(parts[0]) key := strings.TrimSpace(parts[0])
value := core.Trim(parts[1]) value := strings.TrimSpace(parts[1])
// Remove surrounding quotes if present // Remove surrounding quotes if present
value = stripWrappingQuotes(value) value = strings.Trim(value, "\"'")
vars[key] = value vars[key] = value
} }
} }
return vars return vars
} }
func stripWrappingQuotes(value string) string {
if len(value) < 2 {
return value
}
if core.HasPrefix(value, "\"") && core.HasSuffix(value, "\"") {
return core.TrimSuffix(core.TrimPrefix(value, "\""), "\"")
}
if core.HasPrefix(value, "'") && core.HasSuffix(value, "'") {
return core.TrimSuffix(core.TrimPrefix(value, "'"), "'")
}
return value
}

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,10 +25,6 @@ var (
) )
// AddVMCommands adds container-related commands under 'vm' to the CLI. // AddVMCommands adds container-related commands under 'vm' to the CLI.
//
// Usage:
//
// AddVMCommands(root)
func AddVMCommands(root *cli.Command) { func AddVMCommands(root *cli.Command) {
vmCmd := &cli.Command{ vmCmd := &cli.Command{
Use: "vm", Use: "vm",

View file

@ -81,10 +81,6 @@ type Manager interface {
} }
// GenerateID creates a new unique container ID (8 hex characters). // GenerateID creates a new unique container ID (8 hex characters).
//
// Usage:
//
// id, err := GenerateID()
func GenerateID() (string, error) { func GenerateID() (string, error) {
bytes := make([]byte, 4) bytes := make([]byte, 4)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {

View file

@ -2,13 +2,14 @@ package devenv
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
core "dappco.re/go/core" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/coreutil"
"dappco.re/go/core/container/internal/proc"
) )
// ClaudeOptions configures the Claude sandbox session. // ClaudeOptions configures the Claude sandbox session.
@ -26,7 +27,7 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio
return err return err
} }
if !running { if !running {
core.Println("Dev environment not running, booting...") fmt.Println("Dev environment not running, booting...")
if err := d.Boot(ctx, DefaultBootOptions()); err != nil { if err := d.Boot(ctx, DefaultBootOptions()); err != nil {
return coreerr.E("DevOps.Claude", "failed to boot", err) return coreerr.E("DevOps.Claude", "failed to boot", err)
} }
@ -49,22 +50,20 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio
for _, auth := range authTypes { for _, auth := range authTypes {
switch auth { switch auth {
case "anthropic": case "anthropic":
if key := core.Env("ANTHROPIC_API_KEY"); key != "" { if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" {
envVars = append(envVars, core.Concat("ANTHROPIC_API_KEY=", key)) envVars = append(envVars, "ANTHROPIC_API_KEY="+key)
} }
case "git": case "git":
// Forward git config // Forward git config
name, _ := proc.NewCommand("git", "config", "user.name").Output() name, _ := exec.Command("git", "config", "user.name").Output()
email, _ := proc.NewCommand("git", "config", "user.email").Output() email, _ := exec.Command("git", "config", "user.email").Output()
if len(name) > 0 { if len(name) > 0 {
trimmed := core.Trim(string(name)) envVars = append(envVars, "GIT_AUTHOR_NAME="+strings.TrimSpace(string(name)))
envVars = append(envVars, core.Concat("GIT_AUTHOR_NAME=", trimmed)) envVars = append(envVars, "GIT_COMMITTER_NAME="+strings.TrimSpace(string(name)))
envVars = append(envVars, core.Concat("GIT_COMMITTER_NAME=", trimmed))
} }
if len(email) > 0 { if len(email) > 0 {
trimmed := core.Trim(string(email)) envVars = append(envVars, "GIT_AUTHOR_EMAIL="+strings.TrimSpace(string(email)))
envVars = append(envVars, core.Concat("GIT_AUTHOR_EMAIL=", trimmed)) envVars = append(envVars, "GIT_COMMITTER_EMAIL="+strings.TrimSpace(string(email)))
envVars = append(envVars, core.Concat("GIT_COMMITTER_EMAIL=", trimmed))
} }
} }
} }
@ -76,7 +75,7 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio
"-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "UserKnownHostsFile=~/.core/known_hosts",
"-o", "LogLevel=ERROR", "-o", "LogLevel=ERROR",
"-A", // SSH agent forwarding "-A", // SSH agent forwarding
"-p", core.Sprintf("%d", DefaultSSHPort), "-p", fmt.Sprintf("%d", DefaultSSHPort),
} }
args = append(args, "root@localhost") args = append(args, "root@localhost")
@ -89,20 +88,23 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio
args = append(args, claudeCmd) args = append(args, claudeCmd)
// Set environment for SSH // Set environment for SSH
cmd := proc.NewCommandContext(ctx, "ssh", args...) cmd := exec.CommandContext(ctx, "ssh", args...)
cmd.Stdin = proc.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = proc.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = proc.Stderr cmd.Stderr = os.Stderr
// Pass environment variables through SSH // Pass environment variables through SSH
if len(envVars) > 0 { for _, env := range envVars {
cmd.Env = append(proc.Environ(), envVars...) parts := strings.SplitN(env, "=", 2)
if len(parts) == 2 {
cmd.Env = append(os.Environ(), env)
}
} }
core.Println("Starting Claude in sandboxed environment...") fmt.Println("Starting Claude in sandboxed environment...")
core.Println("Project mounted at /app") fmt.Println("Project mounted at /app")
core.Println(core.Concat("Auth forwarded: SSH agent", formatAuthList(opts))) fmt.Println("Auth forwarded: SSH agent" + formatAuthList(opts))
core.Println() fmt.Println()
return cmd.Run() return cmd.Run()
} }
@ -114,27 +116,27 @@ func formatAuthList(opts ClaudeOptions) string {
if len(opts.Auth) == 0 { if len(opts.Auth) == 0 {
return ", gh, anthropic, git" return ", gh, anthropic, git"
} }
return core.Concat(", ", core.Join(", ", opts.Auth...)) return ", " + strings.Join(opts.Auth, ", ")
} }
// CopyGHAuth copies GitHub CLI auth to the VM. // CopyGHAuth copies GitHub CLI auth to the VM.
func (d *DevOps) CopyGHAuth(ctx context.Context) error { func (d *DevOps) CopyGHAuth(ctx context.Context) error {
home := coreutil.HomeDir() home, err := os.UserHomeDir()
if home == "" { if err != nil {
return coreerr.E("DevOps.CopyGHAuth", "home directory not available", nil) return err
} }
ghConfigDir := coreutil.JoinPath(home, ".config", "gh") ghConfigDir := filepath.Join(home, ".config", "gh")
if !io.Local.IsDir(ghConfigDir) { if !io.Local.IsDir(ghConfigDir) {
return nil // No gh config to copy return nil // No gh config to copy
} }
// Use scp to copy gh config // Use scp to copy gh config
cmd := proc.NewCommandContext(ctx, "scp", cmd := exec.CommandContext(ctx, "scp",
"-o", "StrictHostKeyChecking=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "UserKnownHostsFile=~/.core/known_hosts",
"-o", "LogLevel=ERROR", "-o", "LogLevel=ERROR",
"-P", core.Sprintf("%d", DefaultSSHPort), "-P", fmt.Sprintf("%d", DefaultSSHPort),
"-r", ghConfigDir, "-r", ghConfigDir,
"root@localhost:/root/.config/", "root@localhost:/root/.config/",
) )

View file

@ -6,14 +6,14 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestClaudeOptions_Default_Good(t *testing.T) { func TestClaudeOptions_Default(t *testing.T) {
opts := ClaudeOptions{} opts := ClaudeOptions{}
assert.False(t, opts.NoAuth) assert.False(t, opts.NoAuth)
assert.Nil(t, opts.Auth) assert.Nil(t, opts.Auth)
assert.Empty(t, opts.Model) assert.Empty(t, opts.Model)
} }
func TestClaudeOptions_Custom_Good(t *testing.T) { func TestClaudeOptions_Custom(t *testing.T) {
opts := ClaudeOptions{ opts := ClaudeOptions{
NoAuth: true, NoAuth: true,
Auth: []string{"gh", "anthropic"}, Auth: []string{"gh", "anthropic"},
@ -24,19 +24,19 @@ func TestClaudeOptions_Custom_Good(t *testing.T) {
assert.Equal(t, "opus", opts.Model) assert.Equal(t, "opus", opts.Model)
} }
func TestFormatAuthList_NoAuth_Good(t *testing.T) { func TestFormatAuthList_Good_NoAuth(t *testing.T) {
opts := ClaudeOptions{NoAuth: true} opts := ClaudeOptions{NoAuth: true}
result := formatAuthList(opts) result := formatAuthList(opts)
assert.Equal(t, " (none)", result) assert.Equal(t, " (none)", result)
} }
func TestFormatAuthList_Default_Good(t *testing.T) { func TestFormatAuthList_Good_Default(t *testing.T) {
opts := ClaudeOptions{} opts := ClaudeOptions{}
result := formatAuthList(opts) result := formatAuthList(opts)
assert.Equal(t, ", gh, anthropic, git", result) assert.Equal(t, ", gh, anthropic, git", result)
} }
func TestFormatAuthList_CustomAuth_Good(t *testing.T) { func TestFormatAuthList_Good_CustomAuth(t *testing.T) {
opts := ClaudeOptions{ opts := ClaudeOptions{
Auth: []string{"gh"}, Auth: []string{"gh"},
} }
@ -44,7 +44,7 @@ func TestFormatAuthList_CustomAuth_Good(t *testing.T) {
assert.Equal(t, ", gh", result) assert.Equal(t, ", gh", result)
} }
func TestFormatAuthList_MultipleAuth_Good(t *testing.T) { func TestFormatAuthList_Good_MultipleAuth(t *testing.T) {
opts := ClaudeOptions{ opts := ClaudeOptions{
Auth: []string{"gh", "ssh", "git"}, Auth: []string{"gh", "ssh", "git"},
} }
@ -52,7 +52,7 @@ func TestFormatAuthList_MultipleAuth_Good(t *testing.T) {
assert.Equal(t, ", gh, ssh, git", result) assert.Equal(t, ", gh, ssh, git", result)
} }
func TestFormatAuthList_EmptyAuth_Good(t *testing.T) { func TestFormatAuthList_Good_EmptyAuth(t *testing.T) {
opts := ClaudeOptions{ opts := ClaudeOptions{
Auth: []string{}, Auth: []string{},
} }

View file

@ -1,12 +1,11 @@
package devenv package devenv
import ( import (
"dappco.re/go/core/io" "os"
"path/filepath"
"forge.lthn.ai/core/config" "forge.lthn.ai/core/config"
"forge.lthn.ai/core/go-io"
core "dappco.re/go/core"
"dappco.re/go/core/container/internal/coreutil"
) )
// Config holds global devops configuration from ~/.core/config.yaml. // Config holds global devops configuration from ~/.core/config.yaml.
@ -39,10 +38,6 @@ type CDNConfig struct {
} }
// DefaultConfig returns sensible defaults. // DefaultConfig returns sensible defaults.
//
// Usage:
//
// cfg := DefaultConfig()
func DefaultConfig() *Config { func DefaultConfig() *Config {
return &Config{ return &Config{
Version: 1, Version: 1,
@ -59,24 +54,16 @@ func DefaultConfig() *Config {
} }
// ConfigPath returns the path to the config file. // ConfigPath returns the path to the config file.
//
// Usage:
//
// path, err := ConfigPath()
func ConfigPath() (string, error) { func ConfigPath() (string, error) {
home := coreutil.HomeDir() home, err := os.UserHomeDir()
if home == "" { if err != nil {
return "", core.E("ConfigPath", "home directory not available", nil) return "", err
} }
return coreutil.JoinPath(home, ".core", "config.yaml"), nil return filepath.Join(home, ".core", "config.yaml"), nil
} }
// LoadConfig loads configuration from ~/.core/config.yaml using the provided medium. // LoadConfig loads configuration from ~/.core/config.yaml using the provided medium.
// Returns default config if file doesn't exist. // Returns default config if file doesn't exist.
//
// Usage:
//
// cfg, err := LoadConfig(io.Local)
func LoadConfig(m io.Medium) (*Config, error) { func LoadConfig(m io.Medium) (*Config, error) {
configPath, err := ConfigPath() configPath, err := ConfigPath()
if err != nil { if err != nil {

View file

@ -1,33 +1,35 @@
package devenv package devenv
import ( import (
"syscall" "os"
"path/filepath"
"testing" "testing"
"dappco.re/go/core/container/internal/coreutil" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestConfig_DefaultConfig_Good(t *testing.T) { func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
assert.Equal(t, 1, cfg.Version) assert.Equal(t, 1, cfg.Version)
assert.Equal(t, "auto", cfg.Images.Source) assert.Equal(t, "auto", cfg.Images.Source)
assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo) assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo)
} }
func TestConfig_ConfigPath_Good(t *testing.T) { func TestConfigPath(t *testing.T) {
path, err := ConfigPath() path, err := ConfigPath()
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, path, ".core/config.yaml") assert.Contains(t, path, ".core/config.yaml")
} }
func TestConfig_LoadConfig_Good(t *testing.T) { func TestLoadConfig_Good(t *testing.T) {
t.Run("returns default if not exists", func(t *testing.T) { t.Run("returns default if not exists", func(t *testing.T) {
// Mock HOME to a temp dir // Mock HOME to a temp dir
tempHome := t.TempDir() tempHome := t.TempDir()
origHome := os.Getenv("HOME")
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)
defer func() { _ = os.Setenv("HOME", origHome) }()
cfg, err := LoadConfig(io.Local) cfg, err := LoadConfig(io.Local)
assert.NoError(t, err) assert.NoError(t, err)
@ -38,8 +40,8 @@ func TestConfig_LoadConfig_Good(t *testing.T) {
tempHome := t.TempDir() tempHome := t.TempDir()
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)
coreDir := coreutil.JoinPath(tempHome, ".core") coreDir := filepath.Join(tempHome, ".core")
err := io.Local.EnsureDir(coreDir) err := os.MkdirAll(coreDir, 0755)
require.NoError(t, err) require.NoError(t, err)
configData := ` configData := `
@ -49,7 +51,7 @@ images:
cdn: cdn:
url: https://cdn.example.com url: https://cdn.example.com
` `
err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), configData) err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig(io.Local) cfg, err := LoadConfig(io.Local)
@ -60,16 +62,16 @@ images:
}) })
} }
func TestConfig_LoadConfig_Bad(t *testing.T) { func TestLoadConfig_Bad(t *testing.T) {
t.Run("invalid yaml", func(t *testing.T) { t.Run("invalid yaml", func(t *testing.T) {
tempHome := t.TempDir() tempHome := t.TempDir()
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)
coreDir := coreutil.JoinPath(tempHome, ".core") coreDir := filepath.Join(tempHome, ".core")
err := io.Local.EnsureDir(coreDir) err := os.MkdirAll(coreDir, 0755)
require.NoError(t, err) require.NoError(t, err)
err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), "invalid: yaml: :") err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte("invalid: yaml: :"), 0644)
require.NoError(t, err) require.NoError(t, err)
_, err = LoadConfig(io.Local) _, err = LoadConfig(io.Local)
@ -77,7 +79,7 @@ func TestConfig_LoadConfig_Bad(t *testing.T) {
}) })
} }
func TestConfig_Struct_Good(t *testing.T) { func TestConfig_Struct(t *testing.T) {
cfg := &Config{ cfg := &Config{
Version: 2, Version: 2,
Images: ImagesConfig{ Images: ImagesConfig{
@ -100,7 +102,7 @@ func TestConfig_Struct_Good(t *testing.T) {
assert.Equal(t, "https://cdn.example.com", cfg.Images.CDN.URL) assert.Equal(t, "https://cdn.example.com", cfg.Images.CDN.URL)
} }
func TestDefaultConfig_Complete_Good(t *testing.T) { func TestDefaultConfig_Complete(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
assert.Equal(t, 1, cfg.Version) assert.Equal(t, 1, cfg.Version)
assert.Equal(t, "auto", cfg.Images.Source) assert.Equal(t, "auto", cfg.Images.Source)
@ -109,12 +111,12 @@ func TestDefaultConfig_Complete_Good(t *testing.T) {
assert.Empty(t, cfg.Images.CDN.URL) assert.Empty(t, cfg.Images.CDN.URL)
} }
func TestLoadConfig_PartialConfig_Good(t *testing.T) { func TestLoadConfig_Good_PartialConfig(t *testing.T) {
tempHome := t.TempDir() tempHome := t.TempDir()
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)
coreDir := coreutil.JoinPath(tempHome, ".core") coreDir := filepath.Join(tempHome, ".core")
err := io.Local.EnsureDir(coreDir) err := os.MkdirAll(coreDir, 0755)
require.NoError(t, err) require.NoError(t, err)
// Config only specifies source, should merge with defaults // Config only specifies source, should merge with defaults
@ -123,7 +125,7 @@ version: 1
images: images:
source: github source: github
` `
err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), configData) err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig(io.Local) cfg, err := LoadConfig(io.Local)
@ -134,7 +136,7 @@ images:
assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo) assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo)
} }
func TestLoadConfig_AllSourceTypes_Good(t *testing.T) { func TestLoadConfig_Good_AllSourceTypes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
config string config string
@ -189,11 +191,11 @@ images:
tempHome := t.TempDir() tempHome := t.TempDir()
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)
coreDir := coreutil.JoinPath(tempHome, ".core") coreDir := filepath.Join(tempHome, ".core")
err := io.Local.EnsureDir(coreDir) err := os.MkdirAll(coreDir, 0755)
require.NoError(t, err) require.NoError(t, err)
err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), tt.config) err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(tt.config), 0644)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig(io.Local) cfg, err := LoadConfig(io.Local)
@ -203,7 +205,7 @@ images:
} }
} }
func TestImagesConfig_Struct_Good(t *testing.T) { func TestImagesConfig_Struct(t *testing.T) {
ic := ImagesConfig{ ic := ImagesConfig{
Source: "auto", Source: "auto",
GitHub: GitHubConfig{Repo: "test/repo"}, GitHub: GitHubConfig{Repo: "test/repo"},
@ -212,42 +214,42 @@ func TestImagesConfig_Struct_Good(t *testing.T) {
assert.Equal(t, "test/repo", ic.GitHub.Repo) assert.Equal(t, "test/repo", ic.GitHub.Repo)
} }
func TestGitHubConfig_Struct_Good(t *testing.T) { func TestGitHubConfig_Struct(t *testing.T) {
gc := GitHubConfig{Repo: "owner/repo"} gc := GitHubConfig{Repo: "owner/repo"}
assert.Equal(t, "owner/repo", gc.Repo) assert.Equal(t, "owner/repo", gc.Repo)
} }
func TestRegistryConfig_Struct_Good(t *testing.T) { func TestRegistryConfig_Struct(t *testing.T) {
rc := RegistryConfig{Image: "ghcr.io/owner/image:latest"} rc := RegistryConfig{Image: "ghcr.io/owner/image:latest"}
assert.Equal(t, "ghcr.io/owner/image:latest", rc.Image) assert.Equal(t, "ghcr.io/owner/image:latest", rc.Image)
} }
func TestCDNConfig_Struct_Good(t *testing.T) { func TestCDNConfig_Struct(t *testing.T) {
cc := CDNConfig{URL: "https://cdn.example.com/images"} cc := CDNConfig{URL: "https://cdn.example.com/images"}
assert.Equal(t, "https://cdn.example.com/images", cc.URL) assert.Equal(t, "https://cdn.example.com/images", cc.URL)
} }
func TestLoadConfig_UnreadableFile_Bad(t *testing.T) { func TestLoadConfig_Bad_UnreadableFile(t *testing.T) {
// This test is platform-specific and may not work on all systems // This test is platform-specific and may not work on all systems
// Skip if we can't test file permissions properly // Skip if we can't test file permissions properly
if syscall.Getuid() == 0 { if os.Getuid() == 0 {
t.Skip("Skipping permission test when running as root") t.Skip("Skipping permission test when running as root")
} }
tempHome := t.TempDir() tempHome := t.TempDir()
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)
coreDir := coreutil.JoinPath(tempHome, ".core") coreDir := filepath.Join(tempHome, ".core")
err := io.Local.EnsureDir(coreDir) err := os.MkdirAll(coreDir, 0755)
require.NoError(t, err) require.NoError(t, err)
configPath := coreutil.JoinPath(coreDir, "config.yaml") configPath := filepath.Join(coreDir, "config.yaml")
err = io.Local.WriteMode(configPath, "version: 1", 0000) err = os.WriteFile(configPath, []byte("version: 1"), 0000)
require.NoError(t, err) require.NoError(t, err)
_, err = LoadConfig(io.Local) _, err = LoadConfig(io.Local)
assert.Error(t, err) assert.Error(t, err)
// Restore permissions so cleanup works // Restore permissions so cleanup works
_ = syscall.Chmod(configPath, 0644) _ = os.Chmod(configPath, 0644)
} }

View file

@ -3,15 +3,15 @@ package devenv
import ( import (
"context" "context"
"fmt"
"os"
"path/filepath"
"runtime" "runtime"
"time" "time"
core "dappco.re/go/core" "forge.lthn.ai/core/go-container"
"dappco.re/go/core/container" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/coreutil"
) )
const ( const (
@ -28,10 +28,6 @@ type DevOps struct {
} }
// New creates a new DevOps instance using the provided medium. // New creates a new DevOps instance using the provided medium.
//
// Usage:
//
// dev, err := New(io.Local)
func New(m io.Medium) (*DevOps, error) { func New(m io.Medium) (*DevOps, error) {
cfg, err := LoadConfig(m) cfg, err := LoadConfig(m)
if err != nil { if err != nil {
@ -57,41 +53,29 @@ func New(m io.Medium) (*DevOps, error) {
} }
// ImageName returns the platform-specific image name. // ImageName returns the platform-specific image name.
//
// Usage:
//
// name := ImageName()
func ImageName() string { func ImageName() string {
return core.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH) return fmt.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH)
} }
// ImagesDir returns the path to the images directory. // ImagesDir returns the path to the images directory.
//
// Usage:
//
// dir, err := ImagesDir()
func ImagesDir() (string, error) { func ImagesDir() (string, error) {
if dir := core.Env("CORE_IMAGES_DIR"); dir != "" { if dir := os.Getenv("CORE_IMAGES_DIR"); dir != "" {
return dir, nil return dir, nil
} }
home := coreutil.HomeDir() home, err := os.UserHomeDir()
if home == "" { if err != nil {
return "", core.E("ImagesDir", "home directory not available", nil) return "", err
} }
return coreutil.JoinPath(home, ".core", "images"), nil return filepath.Join(home, ".core", "images"), nil
} }
// ImagePath returns the full path to the platform-specific image. // ImagePath returns the full path to the platform-specific image.
//
// Usage:
//
// path, err := ImagePath()
func ImagePath() (string, error) { func ImagePath() (string, error) {
dir, err := ImagesDir() dir, err := ImagesDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return coreutil.JoinPath(dir, ImageName()), nil return filepath.Join(dir, ImageName()), nil
} }
// IsInstalled checks if the dev image is installed. // IsInstalled checks if the dev image is installed.
@ -122,10 +106,6 @@ type BootOptions struct {
} }
// DefaultBootOptions returns sensible defaults. // DefaultBootOptions returns sensible defaults.
//
// Usage:
//
// opts := DefaultBootOptions()
func DefaultBootOptions() BootOptions { func DefaultBootOptions() BootOptions {
return BootOptions{ return BootOptions{
Memory: 4096, Memory: 4096,

View file

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

View file

@ -2,15 +2,15 @@ package devenv
import ( import (
"context" "context"
"io/fs" "encoding/json"
"fmt"
"os"
"path/filepath"
"time" "time"
core "dappco.re/go/core" "forge.lthn.ai/core/go-container/sources"
"dappco.re/go/core/container/sources" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/coreutil"
) )
// ImageManager handles image downloads and updates. // ImageManager handles image downloads and updates.
@ -37,10 +37,6 @@ type ImageInfo struct {
} }
// NewImageManager creates a new image manager. // NewImageManager creates a new image manager.
//
// Usage:
//
// manager, err := NewImageManager(io.Local, cfg)
func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) { func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) {
imagesDir, err := ImagesDir() imagesDir, err := ImagesDir()
if err != nil { if err != nil {
@ -53,7 +49,7 @@ func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) {
} }
// Load or create manifest // Load or create manifest
manifestPath := coreutil.JoinPath(imagesDir, "manifest.json") manifestPath := filepath.Join(imagesDir, "manifest.json")
manifest, err := loadManifest(m, manifestPath) manifest, err := loadManifest(m, manifestPath)
if err != nil { if err != nil {
return nil, err return nil, err
@ -123,7 +119,7 @@ func (m *ImageManager) Install(ctx context.Context, progress func(downloaded, to
return coreerr.E("ImageManager.Install", "failed to get latest version", err) return coreerr.E("ImageManager.Install", "failed to get latest version", err)
} }
core.Print(nil, "Downloading %s from %s...", ImageName(), src.Name()) fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name())
// Download // Download
if err := src.Download(ctx, m.medium, imagesDir, progress); err != nil { if err := src.Download(ctx, m.medium, imagesDir, progress); err != nil {
@ -178,15 +174,14 @@ func loadManifest(m io.Medium, path string) (*Manifest, error) {
content, err := m.Read(path) content, err := m.Read(path)
if err != nil { if err != nil {
if core.Is(err, fs.ErrNotExist) { if os.IsNotExist(err) {
return manifest, nil return manifest, nil
} }
return nil, err return nil, err
} }
result := core.JSONUnmarshalString(content, manifest) if err := json.Unmarshal([]byte(content), manifest); err != nil {
if !result.OK { return nil, err
return nil, result.Value.(error)
} }
manifest.medium = m manifest.medium = m
manifest.path = path manifest.path = path
@ -196,9 +191,9 @@ func loadManifest(m io.Medium, path string) (*Manifest, error) {
// Save writes the manifest to disk. // Save writes the manifest to disk.
func (m *Manifest) Save() error { func (m *Manifest) Save() error {
result := core.JSONMarshal(m) data, err := json.MarshalIndent(m, "", " ")
if !result.OK { if err != nil {
return result.Value.(error) return err
} }
return m.medium.Write(m.path, string(result.Value.([]byte))) return m.medium.Write(m.path, string(data))
} }

View file

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

View file

@ -2,13 +2,13 @@ package devenv
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"path/filepath"
core "dappco.re/go/core" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/coreutil"
"dappco.re/go/core/container/internal/proc"
) )
// ServeOptions configures the dev server. // ServeOptions configures the dev server.
@ -33,7 +33,7 @@ func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions
servePath := projectDir servePath := projectDir
if opts.Path != "" { if opts.Path != "" {
servePath = coreutil.JoinPath(projectDir, opts.Path) servePath = filepath.Join(projectDir, opts.Path)
} }
// Mount project directory via SSHFS // Mount project directory via SSHFS
@ -43,8 +43,8 @@ func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions
// Detect and run serve command // Detect and run serve command
serveCmd := DetectServeCommand(d.medium, servePath) serveCmd := DetectServeCommand(d.medium, servePath)
core.Print(nil, "Starting server: %s", serveCmd) fmt.Printf("Starting server: %s\n", serveCmd)
core.Print(nil, "Listening on http://localhost:%d", opts.Port) fmt.Printf("Listening on http://localhost:%d\n", opts.Port)
// Run serve command via SSH // Run serve command via SSH
return d.sshShell(ctx, []string{"cd", "/app", "&&", serveCmd}) return d.sshShell(ctx, []string{"cd", "/app", "&&", serveCmd})
@ -52,27 +52,26 @@ func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions
// mountProject mounts a directory into the VM via SSHFS. // mountProject mounts a directory into the VM via SSHFS.
func (d *DevOps) mountProject(ctx context.Context, path string) error { func (d *DevOps) mountProject(ctx context.Context, path string) error {
absPath := coreutil.AbsPath(path) absPath, err := filepath.Abs(path)
if err != nil {
return err
}
// Use reverse SSHFS mount // Use reverse SSHFS mount
// The VM connects back to host to mount the directory // The VM connects back to host to mount the directory
cmd := proc.NewCommandContext(ctx, "ssh", cmd := exec.CommandContext(ctx, "ssh",
"-o", "StrictHostKeyChecking=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "UserKnownHostsFile=~/.core/known_hosts",
"-o", "LogLevel=ERROR", "-o", "LogLevel=ERROR",
"-R", "10000:localhost:22", // Reverse tunnel for SSHFS "-R", "10000:localhost:22", // Reverse tunnel for SSHFS
"-p", core.Sprintf("%d", DefaultSSHPort), "-p", fmt.Sprintf("%d", DefaultSSHPort),
"root@localhost", "root@localhost",
core.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", core.Env("USER"), absPath), fmt.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", os.Getenv("USER"), absPath),
) )
return cmd.Run() return cmd.Run()
} }
// DetectServeCommand auto-detects the serve command for a project. // DetectServeCommand auto-detects the serve command for a project.
//
// Usage:
//
// cmd := DetectServeCommand(io.Local, ".")
func DetectServeCommand(m io.Medium, projectDir string) string { func DetectServeCommand(m io.Medium, projectDir string) string {
// Laravel/Octane // Laravel/Octane
if hasFile(m, projectDir, "artisan") { if hasFile(m, projectDir, "artisan") {

View file

@ -1,65 +1,66 @@
package devenv package devenv
import ( import (
"os"
"path/filepath"
"testing" "testing"
"dappco.re/go/core/container/internal/coreutil" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestDetectServeCommand_Laravel_Good(t *testing.T) { func TestDetectServeCommand_Good_Laravel(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := io.Local.Write(coreutil.JoinPath(tmpDir, "artisan"), "#!/usr/bin/env php") err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(io.Local, tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd)
} }
func TestDetectServeCommand_NodeDev_Good(t *testing.T) { func TestDetectServeCommand_Good_NodeDev(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
packageJSON := `{"scripts":{"dev":"vite","start":"node index.js"}}` packageJSON := `{"scripts":{"dev":"vite","start":"node index.js"}}`
err := io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), packageJSON) err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(io.Local, tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "npm run dev -- --host 0.0.0.0", cmd) assert.Equal(t, "npm run dev -- --host 0.0.0.0", cmd)
} }
func TestDetectServeCommand_NodeStart_Good(t *testing.T) { func TestDetectServeCommand_Good_NodeStart(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
packageJSON := `{"scripts":{"start":"node server.js"}}` packageJSON := `{"scripts":{"start":"node server.js"}}`
err := io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), packageJSON) err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(io.Local, tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "npm start", cmd) assert.Equal(t, "npm start", cmd)
} }
func TestDetectServeCommand_PHP_Good(t *testing.T) { func TestDetectServeCommand_Good_PHP(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"require":{}}`) err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(io.Local, tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "frankenphp php-server -l :8000", cmd) assert.Equal(t, "frankenphp php-server -l :8000", cmd)
} }
func TestDetectServeCommand_GoMain_Good(t *testing.T) { func TestDetectServeCommand_Good_GoMain(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example") err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
err = io.Local.Write(coreutil.JoinPath(tmpDir, "main.go"), "package main") err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(io.Local, tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "go run .", cmd) assert.Equal(t, "go run .", cmd)
} }
func TestDetectServeCommand_GoWithoutMain_Good(t *testing.T) { func TestDetectServeCommand_Good_GoWithoutMain(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example") err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
// No main.go, so falls through to fallback // No main.go, so falls through to fallback
@ -67,41 +68,41 @@ func TestDetectServeCommand_GoWithoutMain_Good(t *testing.T) {
assert.Equal(t, "python3 -m http.server 8000", cmd) assert.Equal(t, "python3 -m http.server 8000", cmd)
} }
func TestDetectServeCommand_Django_Good(t *testing.T) { func TestDetectServeCommand_Good_Django(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := io.Local.Write(coreutil.JoinPath(tmpDir, "manage.py"), "#!/usr/bin/env python") err := os.WriteFile(filepath.Join(tmpDir, "manage.py"), []byte("#!/usr/bin/env python"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(io.Local, tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "python manage.py runserver 0.0.0.0:8000", cmd) assert.Equal(t, "python manage.py runserver 0.0.0.0:8000", cmd)
} }
func TestDetectServeCommand_Fallback_Good(t *testing.T) { func TestDetectServeCommand_Good_Fallback(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
cmd := DetectServeCommand(io.Local, tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "python3 -m http.server 8000", cmd) assert.Equal(t, "python3 -m http.server 8000", cmd)
} }
func TestDetectServeCommand_Priority_Good(t *testing.T) { func TestDetectServeCommand_Good_Priority(t *testing.T) {
// Laravel (artisan) should take priority over PHP (composer.json) // Laravel (artisan) should take priority over PHP (composer.json)
tmpDir := t.TempDir() tmpDir := t.TempDir()
err := io.Local.Write(coreutil.JoinPath(tmpDir, "artisan"), "#!/usr/bin/env php") err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
err = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"require":{}}`) err = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(io.Local, tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd)
} }
func TestServeOptions_Default_Good(t *testing.T) { func TestServeOptions_Default(t *testing.T) {
opts := ServeOptions{} opts := ServeOptions{}
assert.Equal(t, 0, opts.Port) assert.Equal(t, 0, opts.Port)
assert.Equal(t, "", opts.Path) assert.Equal(t, "", opts.Path)
} }
func TestServeOptions_Custom_Good(t *testing.T) { func TestServeOptions_Custom(t *testing.T) {
opts := ServeOptions{ opts := ServeOptions{
Port: 3000, Port: 3000,
Path: "public", Path: "public",
@ -110,25 +111,25 @@ func TestServeOptions_Custom_Good(t *testing.T) {
assert.Equal(t, "public", opts.Path) assert.Equal(t, "public", opts.Path)
} }
func TestServe_HasFile_Good(t *testing.T) { func TestHasFile_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
testFile := coreutil.JoinPath(tmpDir, "test.txt") testFile := filepath.Join(tmpDir, "test.txt")
err := io.Local.Write(testFile, "content") err := os.WriteFile(testFile, []byte("content"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, hasFile(io.Local, tmpDir, "test.txt")) assert.True(t, hasFile(io.Local, tmpDir, "test.txt"))
} }
func TestServe_HasFile_Bad(t *testing.T) { func TestHasFile_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
assert.False(t, hasFile(io.Local, tmpDir, "nonexistent.txt")) assert.False(t, hasFile(io.Local, tmpDir, "nonexistent.txt"))
} }
func TestHasFile_Directory_Bad(t *testing.T) { func TestHasFile_Bad_Directory(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
subDir := coreutil.JoinPath(tmpDir, "subdir") subDir := filepath.Join(tmpDir, "subdir")
err := io.Local.EnsureDir(subDir) err := os.Mkdir(subDir, 0755)
assert.NoError(t, err) assert.NoError(t, err)
// hasFile correctly returns false for directories (only true for regular files) // hasFile correctly returns false for directories (only true for regular files)

View file

@ -2,11 +2,11 @@ package devenv
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
core "dappco.re/go/core" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/proc"
) )
// ShellOptions configures the shell connection. // ShellOptions configures the shell connection.
@ -39,7 +39,7 @@ func (d *DevOps) sshShell(ctx context.Context, command []string) error {
"-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "UserKnownHostsFile=~/.core/known_hosts",
"-o", "LogLevel=ERROR", "-o", "LogLevel=ERROR",
"-A", // Agent forwarding "-A", // Agent forwarding
"-p", core.Sprintf("%d", DefaultSSHPort), "-p", fmt.Sprintf("%d", DefaultSSHPort),
"root@localhost", "root@localhost",
} }
@ -47,10 +47,10 @@ func (d *DevOps) sshShell(ctx context.Context, command []string) error {
args = append(args, command...) args = append(args, command...)
} }
cmd := proc.NewCommandContext(ctx, "ssh", args...) cmd := exec.CommandContext(ctx, "ssh", args...)
cmd.Stdin = proc.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = proc.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = proc.Stderr cmd.Stderr = os.Stderr
return cmd.Run() return cmd.Run()
} }
@ -67,10 +67,10 @@ func (d *DevOps) serialConsole(ctx context.Context) error {
} }
// Use socat to connect to the console socket // Use socat to connect to the console socket
socketPath := core.Sprintf("/tmp/core-%s-console.sock", c.ID) socketPath := fmt.Sprintf("/tmp/core-%s-console.sock", c.ID)
cmd := proc.NewCommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath) cmd := exec.CommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath)
cmd.Stdin = proc.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = proc.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = proc.Stderr cmd.Stderr = os.Stderr
return cmd.Run() return cmd.Run()
} }

View file

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

View file

@ -2,37 +2,38 @@ package devenv
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io"
coreio "dappco.re/go/core/io" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/coreutil"
"dappco.re/go/core/container/internal/proc"
) )
// ensureHostKey ensures that the host key for the dev environment is in the known hosts file. // ensureHostKey ensures that the host key for the dev environment is in the known hosts file.
// This is used after boot to allow StrictHostKeyChecking=yes to work. // This is used after boot to allow StrictHostKeyChecking=yes to work.
func ensureHostKey(ctx context.Context, port int) error { func ensureHostKey(ctx context.Context, port int) error {
// Skip if requested (used in tests) // Skip if requested (used in tests)
if core.Env("CORE_SKIP_SSH_SCAN") == "true" { if os.Getenv("CORE_SKIP_SSH_SCAN") == "true" {
return nil return nil
} }
home := coreutil.HomeDir() home, err := os.UserHomeDir()
if home == "" { if err != nil {
return coreerr.E("ensureHostKey", "get home dir", nil) return coreerr.E("ensureHostKey", "get home dir", err)
} }
knownHostsPath := coreutil.JoinPath(home, ".core", "known_hosts") knownHostsPath := filepath.Join(home, ".core", "known_hosts")
// Ensure directory exists // Ensure directory exists
if err := coreio.Local.EnsureDir(core.PathDir(knownHostsPath)); err != nil { if err := coreio.Local.EnsureDir(filepath.Dir(knownHostsPath)); err != nil {
return coreerr.E("ensureHostKey", "create known_hosts dir", err) return coreerr.E("ensureHostKey", "create known_hosts dir", err)
} }
// Get host key using ssh-keyscan // Get host key using ssh-keyscan
cmd := proc.NewCommandContext(ctx, "ssh-keyscan", "-p", core.Sprintf("%d", port), "localhost") cmd := exec.CommandContext(ctx, "ssh-keyscan", "-p", fmt.Sprintf("%d", port), "localhost")
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
return coreerr.E("ensureHostKey", "ssh-keyscan failed", err) return coreerr.E("ensureHostKey", "ssh-keyscan failed", err)
@ -45,27 +46,21 @@ func ensureHostKey(ctx context.Context, port int) error {
// Read existing known_hosts to avoid duplicates // Read existing known_hosts to avoid duplicates
existingStr, _ := coreio.Local.Read(knownHostsPath) existingStr, _ := coreio.Local.Read(knownHostsPath)
if !coreio.Local.Exists(knownHostsPath) {
if err := coreio.Local.WriteMode(knownHostsPath, "", 0600); err != nil {
return coreerr.E("ensureHostKey", "create known_hosts", err)
}
}
// Append new keys that aren't already there // Append new keys that aren't already there
f, err := coreio.Local.Append(knownHostsPath) f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil { if err != nil {
return coreerr.E("ensureHostKey", "open known_hosts", err) return coreerr.E("ensureHostKey", "open known_hosts", err)
} }
defer f.Close() defer f.Close()
lines := core.Split(string(out), "\n") lines := strings.Split(string(out), "\n")
for _, line := range lines { for _, line := range lines {
line = core.Trim(line) line = strings.TrimSpace(line)
if line == "" || core.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
continue continue
} }
if !core.Contains(existingStr, line) { if !strings.Contains(existingStr, line) {
if _, err := f.Write([]byte(core.Concat(line, "\n"))); err != nil { if _, err := f.WriteString(line + "\n"); err != nil {
return coreerr.E("ensureHostKey", "write known_hosts", err) return coreerr.E("ensureHostKey", "write known_hosts", err)
} }
} }

View file

@ -2,13 +2,13 @@ package devenv
import ( import (
"context" "context"
"encoding/json"
"path/filepath"
"strings"
core "dappco.re/go/core" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"dappco.re/go/core/container/internal/coreutil"
) )
// TestConfig holds test configuration from .core/test.yaml. // TestConfig holds test configuration from .core/test.yaml.
@ -45,7 +45,7 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions)
// Priority: explicit command > named command > auto-detect // Priority: explicit command > named command > auto-detect
if len(opts.Command) > 0 { if len(opts.Command) > 0 {
cmd = core.Join(" ", opts.Command...) cmd = strings.Join(opts.Command, " ")
} else if opts.Name != "" { } else if opts.Name != "" {
cfg, err := LoadTestConfig(d.medium, projectDir) cfg, err := LoadTestConfig(d.medium, projectDir)
if err != nil { if err != nil {
@ -72,10 +72,6 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions)
} }
// DetectTestCommand auto-detects the test command for a project. // DetectTestCommand auto-detects the test command for a project.
//
// Usage:
//
// cmd := DetectTestCommand(io.Local, ".")
func DetectTestCommand(m io.Medium, projectDir string) string { func DetectTestCommand(m io.Medium, projectDir string) string {
// 1. Check .core/test.yaml // 1. Check .core/test.yaml
cfg, err := LoadTestConfig(m, projectDir) cfg, err := LoadTestConfig(m, projectDir)
@ -116,12 +112,12 @@ func DetectTestCommand(m io.Medium, projectDir string) string {
} }
// LoadTestConfig loads .core/test.yaml. // LoadTestConfig loads .core/test.yaml.
//
// Usage:
//
// cfg, err := LoadTestConfig(io.Local, ".")
func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) { func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) {
absPath := coreutil.AbsPath(coreutil.JoinPath(projectDir, ".core", "test.yaml")) path := filepath.Join(projectDir, ".core", "test.yaml")
absPath, err := filepath.Abs(path)
if err != nil {
return nil, err
}
content, err := m.Read(absPath) content, err := m.Read(absPath)
if err != nil { if err != nil {
@ -137,12 +133,20 @@ func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) {
} }
func hasFile(m io.Medium, dir, name string) bool { func hasFile(m io.Medium, dir, name string) bool {
absPath := coreutil.AbsPath(coreutil.JoinPath(dir, name)) path := filepath.Join(dir, name)
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
return m.IsFile(absPath) return m.IsFile(absPath)
} }
func hasPackageScript(m io.Medium, projectDir, script string) bool { func hasPackageScript(m io.Medium, projectDir, script string) bool {
absPath := coreutil.AbsPath(coreutil.JoinPath(projectDir, "package.json")) path := filepath.Join(projectDir, "package.json")
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
content, err := m.Read(absPath) content, err := m.Read(absPath)
if err != nil { if err != nil {
@ -152,8 +156,7 @@ func hasPackageScript(m io.Medium, projectDir, script string) bool {
var pkg struct { var pkg struct {
Scripts map[string]string `json:"scripts"` Scripts map[string]string `json:"scripts"`
} }
result := core.JSONUnmarshalString(content, &pkg) if err := json.Unmarshal([]byte(content), &pkg); err != nil {
if !result.OK {
return false return false
} }
@ -162,7 +165,11 @@ func hasPackageScript(m io.Medium, projectDir, script string) bool {
} }
func hasComposerScript(m io.Medium, projectDir, script string) bool { func hasComposerScript(m io.Medium, projectDir, script string) bool {
absPath := coreutil.AbsPath(coreutil.JoinPath(projectDir, "composer.json")) path := filepath.Join(projectDir, "composer.json")
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
content, err := m.Read(absPath) content, err := m.Read(absPath)
if err != nil { if err != nil {
@ -172,8 +179,7 @@ func hasComposerScript(m io.Medium, projectDir, script string) bool {
var pkg struct { var pkg struct {
Scripts map[string]any `json:"scripts"` Scripts map[string]any `json:"scripts"`
} }
result := core.JSONUnmarshalString(content, &pkg) if err := json.Unmarshal([]byte(content), &pkg); err != nil {
if !result.OK {
return false return false
} }

View file

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

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 (core/io, config, core/i18n, cli) requires the workspace file. - **Go workspace** -- This module is part of a Go workspace at `~/Code/go.work`. Local development of sibling modules (go-io, config, go-i18n, cli) requires the workspace file.
Optional (for actually running VMs): Optional (for actually running VMs):
@ -40,7 +40,7 @@ Tests use `testify` for assertions. Most tests are self-contained and do not req
## Test naming convention ## Test naming convention
Tests follow the `TestSubject_Function_{Good,Bad,Ugly}` pattern: Tests follow a `_Good`, `_Bad`, `_Ugly` suffix pattern:
| Suffix | Meaning | | Suffix | Meaning |
|--------|---------| |--------|---------|
@ -51,9 +51,9 @@ Tests follow the `TestSubject_Function_{Good,Bad,Ugly}` pattern:
Examples from the codebase: Examples from the codebase:
```go ```go
func TestState_NewState_Good(t *testing.T) { /* creates state successfully */ } func TestNewState_Good(t *testing.T) { /* creates state successfully */ }
func TestLoadState_InvalidJSON_Bad(t *testing.T) { /* handles corrupt state file */ } func TestLoadState_Bad_InvalidJSON(t *testing.T) { /* handles corrupt state file */ }
func TestGetHypervisor_Unknown_Bad(t *testing.T) { /* rejects unknown hypervisor name */ } func TestGetHypervisor_Bad_Unknown(t *testing.T) { /* rejects unknown hypervisor name */ }
``` ```
@ -108,8 +108,8 @@ go-container/
- **UK English** in all strings, comments, and documentation (colour, organisation, honour). - **UK English** in all strings, comments, and documentation (colour, organisation, honour).
- **Strict typing** -- All function parameters and return values are typed. No `interface{}` without justification. - **Strict typing** -- All function parameters and return values are typed. No `interface{}` without justification.
- **Error wrapping** -- Use `core.E("Op", "message", err)` rather than `fmt.Errorf`. - **Error wrapping** -- Use `fmt.Errorf("context: %w", err)` for all error returns.
- **`io.Medium` abstraction** -- File system operations go through `io.Medium` (from `core/io`) rather than directly calling `os` functions. This enables testing with mock file systems. The `io.Local` singleton is used for real file system access. - **`io.Medium` abstraction** -- File system operations go through `io.Medium` (from `go-io`) rather than directly calling `os` functions. This enables testing with mock file systems. The `io.Local` singleton is used for real file system access.
- **Compile-time interface checks** -- Use `var _ Interface = (*Impl)(nil)` to verify implementations at compile time (see `sources/cdn.go` and `sources/github.go`). - **Compile-time interface checks** -- Use `var _ Interface = (*Impl)(nil)` to verify implementations at compile time (see `sources/cdn.go` and `sources/github.go`).
- **Context propagation** -- All operations that might block accept a `context.Context` as their first parameter. - **Context propagation** -- All operations that might block accept a `context.Context` as their first parameter.
@ -125,14 +125,14 @@ type MyHypervisor struct {
func (h *MyHypervisor) Name() string { return "my-hypervisor" } func (h *MyHypervisor) Name() string { return "my-hypervisor" }
func (h *MyHypervisor) Available() bool { /* check if binary exists */ } func (h *MyHypervisor) Available() bool { /* check if binary exists */ }
func (h *MyHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) { func (h *MyHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) {
// Build and return the command. // Build and return exec.Cmd
} }
``` ```
2. Register it in `DetectHypervisor()` and `GetHypervisor()` in `hypervisor.go`. 2. Register it in `DetectHypervisor()` and `GetHypervisor()` in `hypervisor.go`.
3. Add tests following the `TestSubject_Function_{Good,Bad,Ugly}` naming convention. 3. Add tests following the `_Good`/`_Bad` naming convention.
## Adding a new image source ## Adding a new image source

View file

@ -5,7 +5,7 @@ description: Container runtime, LinuxKit image builder, and portable development
# go-container # go-container
`dappco.re/go/core/container` provides a container runtime built on LinuxKit and lightweight hypervisors. It manages the full lifecycle of LinuxKit virtual machines -- from building images with embedded templates, to running them via QEMU or Hyperkit, to offering a portable development environment with shell access, project mounting, test execution, and Claude AI integration. `forge.lthn.ai/core/go-container` provides a container runtime built on LinuxKit and lightweight hypervisors. It manages the full lifecycle of LinuxKit virtual machines -- from building images with embedded templates, to running them via QEMU or Hyperkit, to offering a portable development environment with shell access, project mounting, test execution, and Claude AI integration.
This is **not** a Docker wrapper. It runs real VMs from LinuxKit images (ISO, qcow2, VMDK, raw) using platform-native acceleration (KVM on Linux, HVF on macOS, Hyperkit where available). This is **not** a Docker wrapper. It runs real VMs from LinuxKit images (ISO, qcow2, VMDK, raw) using platform-native acceleration (KVM on Linux, HVF on macOS, Hyperkit where available).
@ -13,7 +13,7 @@ This is **not** a Docker wrapper. It runs real VMs from LinuxKit images (ISO, qc
## Module path ## Module path
``` ```
dappco.re/go/core/container forge.lthn.ai/core/go-container
``` ```
Requires **Go 1.26+**. Requires **Go 1.26+**.
@ -26,8 +26,8 @@ Requires **Go 1.26+**.
```go ```go
import ( import (
"context" "context"
container "dappco.re/go/core/container" container "forge.lthn.ai/core/go-container"
"dappco.re/go/core/io" "forge.lthn.ai/core/go-io"
) )
manager, err := container.NewLinuxKitManager(io.Local) manager, err := container.NewLinuxKitManager(io.Local)
@ -54,8 +54,8 @@ fmt.Printf("Started container %s (PID %d)\n", c.ID, c.PID)
```go ```go
import ( import (
"dappco.re/go/core/container/devenv" "forge.lthn.ai/core/go-container/devenv"
"dappco.re/go/core/io" "forge.lthn.ai/core/go-io"
) )
dev, err := devenv.New(io.Local) dev, err := devenv.New(io.Local)
@ -77,7 +77,7 @@ err = dev.Test(ctx, "/path/to/project", devenv.TestOptions{})
### Build and run from a LinuxKit template ### Build and run from a LinuxKit template
```go ```go
import container "dappco.re/go/core/container" import container "forge.lthn.ai/core/go-container"
// List available templates (built-in + user-defined) // List available templates (built-in + user-defined)
templates := container.ListTemplates() templates := container.ListTemplates()
@ -95,24 +95,24 @@ content, err := container.ApplyTemplate("core-dev", map[string]string{
| Package | Import path | Purpose | | Package | Import path | Purpose |
|---------|-------------|---------| |---------|-------------|---------|
| `container` (root) | `dappco.re/go/core/container` | Container struct, Manager interface, hypervisor abstraction, LinuxKit manager, state persistence, template engine | | `container` (root) | `forge.lthn.ai/core/go-container` | Container struct, Manager interface, hypervisor abstraction, LinuxKit manager, state persistence, template engine |
| `devenv` | `dappco.re/go/core/container/devenv` | Portable dev environment orchestration: boot, shell, serve, test, Claude sandbox, image management | | `devenv` | `forge.lthn.ai/core/go-container/devenv` | Portable dev environment orchestration: boot, shell, serve, test, Claude sandbox, image management |
| `sources` | `dappco.re/go/core/container/sources` | Image download backends: CDN and GitHub Releases with progress reporting | | `sources` | `forge.lthn.ai/core/go-container/sources` | Image download backends: CDN and GitHub Releases with progress reporting |
| `cmd/vm` | `dappco.re/go/core/container/cmd/vm` | CLI commands (`core vm run`, `core vm ps`, `core vm stop`, `core vm logs`, `core vm exec`, `core vm templates`) | | `cmd/vm` | `forge.lthn.ai/core/go-container/cmd/vm` | CLI commands (`core vm run`, `core vm ps`, `core vm stop`, `core vm logs`, `core vm exec`, `core vm templates`) |
## Dependencies ## Dependencies
| Module | Purpose | | Module | Purpose |
|--------|---------| |--------|---------|
| `dappco.re/go/core/io` | File system abstraction (`Medium` interface), process utilities | | `forge.lthn.ai/core/go-io` | File system abstraction (`Medium` interface), process utilities |
| `forge.lthn.ai/core/config` | Configuration loading (used by `devenv` for `~/.core/config.yaml`) | | `forge.lthn.ai/core/config` | Configuration loading (used by `devenv` for `~/.core/config.yaml`) |
| `dappco.re/go/core/i18n` | Internationalised UI strings (used by `cmd/vm`) | | `forge.lthn.ai/core/go-i18n` | Internationalised UI strings (used by `cmd/vm`) |
| `forge.lthn.ai/core/cli` | CLI framework (used by `cmd/vm` for command registration) | | `forge.lthn.ai/core/cli` | CLI framework (used by `cmd/vm` for command registration) |
| `github.com/stretchr/testify` | Test assertions | | `github.com/stretchr/testify` | Test assertions |
| `gopkg.in/yaml.v3` | YAML parsing for test configuration | | `gopkg.in/yaml.v3` | YAML parsing for test configuration |
The root `container` package has only two direct dependencies: `core/io` and the standard library. The `devenv` and `cmd/vm` packages pull in the heavier dependencies. The root `container` package has only two direct dependencies: `go-io` and the standard library. The `devenv` and `cmd/vm` packages pull in the heavier dependencies.
## CLI commands ## CLI commands

25
go.mod
View file

@ -1,24 +1,23 @@
module dappco.re/go/core/container module forge.lthn.ai/core/go-container
go 1.26.0 go 1.26.0
require ( require (
dappco.re/go/core v0.8.0-alpha.1 forge.lthn.ai/core/cli v0.3.1
dappco.re/go/core/i18n v0.2.0 forge.lthn.ai/core/config v0.1.3
dappco.re/go/core/io v0.2.0 forge.lthn.ai/core/go-i18n v0.1.4
dappco.re/go/core/log v0.1.0 forge.lthn.ai/core/go-io v0.1.2
forge.lthn.ai/core/cli v0.3.7 forge.lthn.ai/core/go-log v0.0.4
forge.lthn.ai/core/config v0.1.8
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
forge.lthn.ai/core/go v0.3.3 // indirect forge.lthn.ai/core/go v0.3.1 // indirect
forge.lthn.ai/core/go-i18n v0.1.7 // indirect forge.lthn.ai/core/go-crypt v0.1.7 // indirect
forge.lthn.ai/core/go-inference v0.1.6 // indirect forge.lthn.ai/core/go-inference v0.1.4 // indirect
forge.lthn.ai/core/go-io v0.1.7 // indirect forge.lthn.ai/core/go-process v0.2.3 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
@ -28,6 +27,7 @@ require (
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
@ -52,6 +52,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect

42
go.sum
View file

@ -1,25 +1,23 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= forge.lthn.ai/core/cli v0.3.1 h1:ZpHhaDrdbaV98JDxj/f0E5nytYk9tTMRu3qohGyK4M0=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= forge.lthn.ai/core/cli v0.3.1/go.mod h1:28cOl9eK0H033Otkjrv9f/QCmtHcJl+IIx4om8JskOg=
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI= forge.lthn.ai/core/config v0.1.3 h1:mq02v7LFf9jHSqJakO08qYQnPP8oVMbJHlOxNARXBa8=
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok= forge.lthn.ai/core/config v0.1.3/go.mod h1:4+/ytojOSaPoAQ1uub1+GeOM8OoYdR9xqMtVA3SZ8Qk=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= forge.lthn.ai/core/go-crypt v0.1.7 h1:tyDFnXjEksHFQpkFwCpEn+x7zvwh4LnaU+/fP3WmqZc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= forge.lthn.ai/core/go-crypt v0.1.7/go.mod h1:mQdr6K8lWOcyHmSEW24vZPTThQF8fteVgZi8CO+Ko3Y=
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= forge.lthn.ai/core/go-i18n v0.1.4 h1:zOHUUJDgRo88/3tj++kN+VELg/buyZ4T2OSdG3HBbLQ=
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= forge.lthn.ai/core/go-i18n v0.1.4/go.mod h1:aDyAfz7MMgWYgLkZCptfFmZ7jJg3ocwjEJ1WkJSvv4U=
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg= forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ= forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= forge.lthn.ai/core/go-io v0.1.2 h1:q8hj2jtOFqAgHlBr5wsUAOXtaFkxy9gqGrQT/il0WYA=
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= forge.lthn.ai/core/go-io v0.1.2/go.mod h1:PbNKW1Q25ywSOoQXeGdQHbV5aiIrTXvHIQ5uhplA//g=
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E=
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-process v0.2.3 h1:/ERqRYHgCNZjNT9NMinAAJJGJWSsHuCTiHFNEm6nTPY=
forge.lthn.ai/core/go-process v0.2.3/go.mod h1:gVTbxL16ccUIexlFcyDtCy7LfYvD8Rtyzfo8bnXAXrU=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@ -38,6 +36,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -101,6 +101,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View file

@ -2,13 +2,14 @@ package container
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime" "runtime"
"strings"
core "dappco.re/go/core" coreerr "forge.lthn.ai/core/go-log"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/proc"
) )
// Hypervisor defines the interface for VM hypervisors. // Hypervisor defines the interface for VM hypervisors.
@ -18,7 +19,7 @@ type Hypervisor interface {
// Available checks if the hypervisor is available on the system. // Available checks if the hypervisor is available on the system.
Available() bool Available() bool
// BuildCommand builds the command to run a VM with the given options. // BuildCommand builds the command to run a VM with the given options.
BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error)
} }
// HypervisorOptions contains options for running a VM. // HypervisorOptions contains options for running a VM.
@ -46,10 +47,6 @@ type QemuHypervisor struct {
} }
// NewQemuHypervisor creates a new QEMU hypervisor instance. // NewQemuHypervisor creates a new QEMU hypervisor instance.
//
// Usage:
//
// hv := NewQemuHypervisor()
func NewQemuHypervisor() *QemuHypervisor { func NewQemuHypervisor() *QemuHypervisor {
return &QemuHypervisor{ return &QemuHypervisor{
Binary: "qemu-system-x86_64", Binary: "qemu-system-x86_64",
@ -63,20 +60,20 @@ func (q *QemuHypervisor) Name() string {
// Available checks if QEMU is installed and accessible. // Available checks if QEMU is installed and accessible.
func (q *QemuHypervisor) Available() bool { func (q *QemuHypervisor) Available() bool {
_, err := proc.LookPath(q.Binary) _, err := exec.LookPath(q.Binary)
return err == nil return err == nil
} }
// BuildCommand creates the QEMU command for running a VM. // BuildCommand creates the QEMU command for running a VM.
func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) { func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) {
format := DetectImageFormat(image) format := DetectImageFormat(image)
if format == FormatUnknown { if format == FormatUnknown {
return nil, coreerr.E("QemuHypervisor.BuildCommand", "unknown image format: "+image, nil) return nil, coreerr.E("QemuHypervisor.BuildCommand", "unknown image format: "+image, nil)
} }
args := []string{ args := []string{
"-m", core.Sprintf("%d", opts.Memory), "-m", fmt.Sprintf("%d", opts.Memory),
"-smp", core.Sprintf("%d", opts.CPUs), "-smp", fmt.Sprintf("%d", opts.CPUs),
"-enable-kvm", "-enable-kvm",
} }
@ -86,11 +83,11 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H
args = append(args, "-cdrom", image) args = append(args, "-cdrom", image)
args = append(args, "-boot", "d") args = append(args, "-boot", "d")
case FormatQCOW2: case FormatQCOW2:
args = append(args, "-drive", core.Sprintf("file=%s,format=qcow2", image)) args = append(args, "-drive", fmt.Sprintf("file=%s,format=qcow2", image))
case FormatVMDK: case FormatVMDK:
args = append(args, "-drive", core.Sprintf("file=%s,format=vmdk", image)) args = append(args, "-drive", fmt.Sprintf("file=%s,format=vmdk", image))
case FormatRaw: case FormatRaw:
args = append(args, "-drive", core.Sprintf("file=%s,format=raw", image)) args = append(args, "-drive", fmt.Sprintf("file=%s,format=raw", image))
} }
// Always run in nographic mode for container-like behavior // Always run in nographic mode for container-like behavior
@ -102,10 +99,10 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H
// Network with port forwarding // Network with port forwarding
netdev := "user,id=net0" netdev := "user,id=net0"
if opts.SSHPort > 0 { if opts.SSHPort > 0 {
netdev += core.Sprintf(",hostfwd=tcp::%d-:22", opts.SSHPort) netdev += fmt.Sprintf(",hostfwd=tcp::%d-:22", opts.SSHPort)
} }
for hostPort, guestPort := range opts.Ports { for hostPort, guestPort := range opts.Ports {
netdev += core.Sprintf(",hostfwd=tcp::%d-:%d", hostPort, guestPort) netdev += fmt.Sprintf(",hostfwd=tcp::%d-:%d", hostPort, guestPort)
} }
args = append(args, "-netdev", netdev) args = append(args, "-netdev", netdev)
args = append(args, "-device", "virtio-net-pci,netdev=net0") args = append(args, "-device", "virtio-net-pci,netdev=net0")
@ -113,10 +110,10 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H
// Add 9p shares for volumes // Add 9p shares for volumes
shareID := 0 shareID := 0
for hostPath, guestPath := range opts.Volumes { for hostPath, guestPath := range opts.Volumes {
tag := core.Sprintf("share%d", shareID) tag := fmt.Sprintf("share%d", shareID)
args = append(args, args = append(args,
"-fsdev", core.Sprintf("local,id=%s,path=%s,security_model=none", tag, hostPath), "-fsdev", fmt.Sprintf("local,id=%s,path=%s,security_model=none", tag, hostPath),
"-device", core.Sprintf("virtio-9p-pci,fsdev=%s,mount_tag=%s", tag, core.PathBase(guestPath)), "-device", fmt.Sprintf("virtio-9p-pci,fsdev=%s,mount_tag=%s", tag, filepath.Base(guestPath)),
) )
shareID++ shareID++
} }
@ -138,12 +135,14 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H
} }
} }
return proc.NewCommandContext(ctx, q.Binary, args...), nil cmd := exec.CommandContext(ctx, q.Binary, args...)
return cmd, nil
} }
// isKVMAvailable checks if KVM is available on the system. // isKVMAvailable checks if KVM is available on the system.
func isKVMAvailable() bool { func isKVMAvailable() bool {
return coreio.Local.Exists("/dev/kvm") _, err := os.Stat("/dev/kvm")
return err == nil
} }
// HyperkitHypervisor implements Hypervisor for macOS Hyperkit. // HyperkitHypervisor implements Hypervisor for macOS Hyperkit.
@ -153,10 +152,6 @@ type HyperkitHypervisor struct {
} }
// NewHyperkitHypervisor creates a new Hyperkit hypervisor instance. // NewHyperkitHypervisor creates a new Hyperkit hypervisor instance.
//
// Usage:
//
// hv := NewHyperkitHypervisor()
func NewHyperkitHypervisor() *HyperkitHypervisor { func NewHyperkitHypervisor() *HyperkitHypervisor {
return &HyperkitHypervisor{ return &HyperkitHypervisor{
Binary: "hyperkit", Binary: "hyperkit",
@ -173,20 +168,20 @@ func (h *HyperkitHypervisor) Available() bool {
if runtime.GOOS != "darwin" { if runtime.GOOS != "darwin" {
return false return false
} }
_, err := proc.LookPath(h.Binary) _, err := exec.LookPath(h.Binary)
return err == nil return err == nil
} }
// BuildCommand creates the Hyperkit command for running a VM. // BuildCommand creates the Hyperkit command for running a VM.
func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) { func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) {
format := DetectImageFormat(image) format := DetectImageFormat(image)
if format == FormatUnknown { if format == FormatUnknown {
return nil, coreerr.E("HyperkitHypervisor.BuildCommand", "unknown image format: "+image, nil) return nil, coreerr.E("HyperkitHypervisor.BuildCommand", "unknown image format: "+image, nil)
} }
args := []string{ args := []string{
"-m", core.Sprintf("%dM", opts.Memory), "-m", fmt.Sprintf("%dM", opts.Memory),
"-c", core.Sprintf("%d", opts.CPUs), "-c", fmt.Sprintf("%d", opts.CPUs),
"-A", // ACPI "-A", // ACPI
"-u", // Unlimited console output "-u", // Unlimited console output
"-s", "0:0,hostbridge", "-s", "0:0,hostbridge",
@ -197,9 +192,9 @@ func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opt
// Add PCI slot for disk (slot 2) // Add PCI slot for disk (slot 2)
switch format { switch format {
case FormatISO: case FormatISO:
args = append(args, "-s", core.Sprintf("2:0,ahci-cd,%s", image)) args = append(args, "-s", fmt.Sprintf("2:0,ahci-cd,%s", image))
case FormatQCOW2, FormatVMDK, FormatRaw: case FormatQCOW2, FormatVMDK, FormatRaw:
args = append(args, "-s", core.Sprintf("2:0,virtio-blk,%s", image)) args = append(args, "-s", fmt.Sprintf("2:0,virtio-blk,%s", image))
} }
// Network with port forwarding (slot 3) // Network with port forwarding (slot 3)
@ -208,27 +203,24 @@ func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opt
// Hyperkit uses slirp for user networking with port forwarding // Hyperkit uses slirp for user networking with port forwarding
portForwards := make([]string, 0) portForwards := make([]string, 0)
if opts.SSHPort > 0 { if opts.SSHPort > 0 {
portForwards = append(portForwards, core.Sprintf("tcp:%d:22", opts.SSHPort)) portForwards = append(portForwards, fmt.Sprintf("tcp:%d:22", opts.SSHPort))
} }
for hostPort, guestPort := range opts.Ports { for hostPort, guestPort := range opts.Ports {
portForwards = append(portForwards, core.Sprintf("tcp:%d:%d", hostPort, guestPort)) portForwards = append(portForwards, fmt.Sprintf("tcp:%d:%d", hostPort, guestPort))
} }
if len(portForwards) > 0 { if len(portForwards) > 0 {
netArgs += "," + core.Join(",", portForwards...) netArgs += "," + strings.Join(portForwards, ",")
} }
} }
args = append(args, "-s", "3:0,"+netArgs) args = append(args, "-s", "3:0,"+netArgs)
return proc.NewCommandContext(ctx, h.Binary, args...), nil cmd := exec.CommandContext(ctx, h.Binary, args...)
return cmd, nil
} }
// DetectImageFormat determines the image format from its file extension. // DetectImageFormat determines the image format from its file extension.
//
// Usage:
//
// format := DetectImageFormat("/tmp/core-dev.qcow2")
func DetectImageFormat(path string) ImageFormat { func DetectImageFormat(path string) ImageFormat {
ext := core.Lower(core.PathExt(path)) ext := strings.ToLower(filepath.Ext(path))
switch ext { switch ext {
case ".iso": case ".iso":
return FormatISO return FormatISO
@ -244,10 +236,6 @@ func DetectImageFormat(path string) ImageFormat {
} }
// DetectHypervisor returns the best available hypervisor for the current platform. // DetectHypervisor returns the best available hypervisor for the current platform.
//
// Usage:
//
// hv, err := DetectHypervisor()
func DetectHypervisor() (Hypervisor, error) { func DetectHypervisor() (Hypervisor, error) {
// On macOS, prefer Hyperkit if available, fall back to QEMU // On macOS, prefer Hyperkit if available, fall back to QEMU
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
@ -267,12 +255,8 @@ func DetectHypervisor() (Hypervisor, error) {
} }
// GetHypervisor returns a specific hypervisor by name. // GetHypervisor returns a specific hypervisor by name.
//
// Usage:
//
// hv, err := GetHypervisor("qemu")
func GetHypervisor(name string) (Hypervisor, error) { func GetHypervisor(name string) (Hypervisor, error) {
switch core.Lower(name) { switch strings.ToLower(name) {
case "qemu": case "qemu":
h := NewQemuHypervisor() h := NewQemuHypervisor()
if !h.Available() { if !h.Available() {

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_InvalidBinary_Bad(t *testing.T) { func TestQemuHypervisor_Available_Bad_InvalidBinary(t *testing.T) {
q := &QemuHypervisor{ q := &QemuHypervisor{
Binary: "nonexistent-qemu-binary-that-does-not-exist", Binary: "nonexistent-qemu-binary-that-does-not-exist",
} }
@ -44,7 +44,7 @@ func TestHyperkitHypervisor_Available_Good(t *testing.T) {
} }
} }
func TestHyperkitHypervisor_Available_NotDarwin_Bad(t *testing.T) { func TestHyperkitHypervisor_Available_Bad_NotDarwin(t *testing.T) {
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
t.Skip("This test only runs on non-darwin systems") t.Skip("This test only runs on non-darwin systems")
} }
@ -56,7 +56,7 @@ func TestHyperkitHypervisor_Available_NotDarwin_Bad(t *testing.T) {
assert.False(t, available, "Hyperkit should not be available on non-darwin systems") assert.False(t, available, "Hyperkit should not be available on non-darwin systems")
} }
func TestHyperkitHypervisor_Available_InvalidBinary_Bad(t *testing.T) { func TestHyperkitHypervisor_Available_Bad_InvalidBinary(t *testing.T) {
h := &HyperkitHypervisor{ h := &HyperkitHypervisor{
Binary: "nonexistent-hyperkit-binary-that-does-not-exist", Binary: "nonexistent-hyperkit-binary-that-does-not-exist",
} }
@ -66,7 +66,7 @@ func TestHyperkitHypervisor_Available_InvalidBinary_Bad(t *testing.T) {
assert.False(t, available) assert.False(t, available)
} }
func TestHypervisor_IsKVMAvailable_Good(t *testing.T) { func TestIsKVMAvailable_Good(t *testing.T) {
// This test verifies the function runs without error // This test verifies the function runs without error
// The actual result depends on the system // The actual result depends on the system
result := isKVMAvailable() result := isKVMAvailable()
@ -80,7 +80,7 @@ func TestHypervisor_IsKVMAvailable_Good(t *testing.T) {
} }
} }
func TestHypervisor_DetectHypervisor_Good(t *testing.T) { func TestDetectHypervisor_Good(t *testing.T) {
// DetectHypervisor tries to find an available hypervisor // DetectHypervisor tries to find an available hypervisor
hv, err := DetectHypervisor() hv, err := DetectHypervisor()
@ -95,7 +95,7 @@ func TestHypervisor_DetectHypervisor_Good(t *testing.T) {
} }
} }
func TestGetHypervisor_Qemu_Good(t *testing.T) { func TestGetHypervisor_Good_Qemu(t *testing.T) {
hv, err := GetHypervisor("qemu") hv, err := GetHypervisor("qemu")
// Depends on whether qemu is installed // Depends on whether qemu is installed
@ -107,7 +107,7 @@ func TestGetHypervisor_Qemu_Good(t *testing.T) {
} }
} }
func TestGetHypervisor_QemuUppercase_Good(t *testing.T) { func TestGetHypervisor_Good_QemuUppercase(t *testing.T) {
hv, err := GetHypervisor("QEMU") hv, err := GetHypervisor("QEMU")
// Depends on whether qemu is installed // Depends on whether qemu is installed
@ -119,7 +119,7 @@ func TestGetHypervisor_QemuUppercase_Good(t *testing.T) {
} }
} }
func TestGetHypervisor_Hyperkit_Good(t *testing.T) { func TestGetHypervisor_Good_Hyperkit(t *testing.T) {
hv, err := GetHypervisor("hyperkit") hv, err := GetHypervisor("hyperkit")
// On non-darwin systems, should always fail // On non-darwin systems, should always fail
@ -137,14 +137,14 @@ func TestGetHypervisor_Hyperkit_Good(t *testing.T) {
} }
} }
func TestGetHypervisor_Unknown_Bad(t *testing.T) { func TestGetHypervisor_Bad_Unknown(t *testing.T) {
_, err := GetHypervisor("unknown-hypervisor") _, err := GetHypervisor("unknown-hypervisor")
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown hypervisor") assert.Contains(t, err.Error(), "unknown hypervisor")
} }
func TestQemuHypervisor_BuildCommand_WithPortsAndVolumes_Good(t *testing.T) { func TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -172,7 +172,7 @@ func TestQemuHypervisor_BuildCommand_WithPortsAndVolumes_Good(t *testing.T) {
assert.Contains(t, args, "4") assert.Contains(t, args, "4")
} }
func TestQemuHypervisor_BuildCommand_QCow2Format_Good(t *testing.T) { func TestQemuHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -192,7 +192,7 @@ func TestQemuHypervisor_BuildCommand_QCow2Format_Good(t *testing.T) {
assert.True(t, found, "Should have qcow2 drive argument") assert.True(t, found, "Should have qcow2 drive argument")
} }
func TestQemuHypervisor_BuildCommand_VMDKFormat_Good(t *testing.T) { func TestQemuHypervisor_BuildCommand_Good_VMDKFormat(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -212,7 +212,7 @@ func TestQemuHypervisor_BuildCommand_VMDKFormat_Good(t *testing.T) {
assert.True(t, found, "Should have vmdk drive argument") assert.True(t, found, "Should have vmdk drive argument")
} }
func TestQemuHypervisor_BuildCommand_RawFormat_Good(t *testing.T) { func TestQemuHypervisor_BuildCommand_Good_RawFormat(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -232,7 +232,7 @@ func TestQemuHypervisor_BuildCommand_RawFormat_Good(t *testing.T) {
assert.True(t, found, "Should have raw drive argument") assert.True(t, found, "Should have raw drive argument")
} }
func TestHyperkitHypervisor_BuildCommand_WithPorts_Good(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_Good_WithPorts(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()
@ -255,7 +255,7 @@ func TestHyperkitHypervisor_BuildCommand_WithPorts_Good(t *testing.T) {
assert.Contains(t, args, "2") assert.Contains(t, args, "2")
} }
func TestHyperkitHypervisor_BuildCommand_QCow2Format_Good(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()
@ -266,7 +266,7 @@ func TestHyperkitHypervisor_BuildCommand_QCow2Format_Good(t *testing.T) {
assert.NotNil(t, cmd) assert.NotNil(t, cmd)
} }
func TestHyperkitHypervisor_BuildCommand_RawFormat_Good(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_Good_RawFormat(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()
@ -277,7 +277,7 @@ func TestHyperkitHypervisor_BuildCommand_RawFormat_Good(t *testing.T) {
assert.NotNil(t, cmd) assert.NotNil(t, cmd)
} }
func TestHyperkitHypervisor_BuildCommand_NoPorts_Good(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_Good_NoPorts(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()
@ -293,7 +293,7 @@ func TestHyperkitHypervisor_BuildCommand_NoPorts_Good(t *testing.T) {
assert.NotNil(t, cmd) assert.NotNil(t, cmd)
} }
func TestQemuHypervisor_BuildCommand_NoSSHPort_Good(t *testing.T) { func TestQemuHypervisor_BuildCommand_Good_NoSSHPort(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -309,7 +309,7 @@ func TestQemuHypervisor_BuildCommand_NoSSHPort_Good(t *testing.T) {
assert.NotNil(t, cmd) assert.NotNil(t, cmd)
} }
func TestQemuHypervisor_BuildCommand_UnknownFormat_Bad(t *testing.T) { func TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) {
q := NewQemuHypervisor() q := NewQemuHypervisor()
ctx := context.Background() ctx := context.Background()
@ -320,7 +320,7 @@ func TestQemuHypervisor_BuildCommand_UnknownFormat_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "unknown image format") assert.Contains(t, err.Error(), "unknown image format")
} }
func TestHyperkitHypervisor_BuildCommand_UnknownFormat_Bad(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()
@ -336,7 +336,7 @@ func TestHyperkitHypervisor_Name_Good(t *testing.T) {
assert.Equal(t, "hyperkit", h.Name()) assert.Equal(t, "hyperkit", h.Name())
} }
func TestHyperkitHypervisor_BuildCommand_ISOFormat_Good(t *testing.T) { func TestHyperkitHypervisor_BuildCommand_Good_ISOFormat(t *testing.T) {
h := NewHyperkitHypervisor() h := NewHyperkitHypervisor()
ctx := context.Background() ctx := context.Background()

View file

@ -1,76 +0,0 @@
package coreutil
import (
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
)
// DirSep returns the active directory separator.
func DirSep() string {
if ds := core.Env("DS"); ds != "" {
return ds
}
return "/"
}
// JoinPath joins path segments using the active directory separator.
func JoinPath(parts ...string) string {
if len(parts) == 0 {
return ""
}
return core.CleanPath(core.Join(DirSep(), parts...), DirSep())
}
// HomeDir returns the current home directory, honouring test-time env overrides.
func HomeDir() string {
if home := core.Env("CORE_HOME"); home != "" {
return home
}
if home := core.Env("HOME"); home != "" {
return home
}
if home := core.Env("USERPROFILE"); home != "" {
return home
}
return core.Env("DIR_HOME")
}
// CurrentDir returns the current working directory, honouring shell PWD.
func CurrentDir() string {
if pwd := core.Env("PWD"); pwd != "" {
return pwd
}
return core.Env("DIR_CWD")
}
// TempDir returns the process temp directory, honouring TMPDIR.
func TempDir() string {
if dir := core.Env("TMPDIR"); dir != "" {
return dir
}
return core.Env("DIR_TMP")
}
// AbsPath resolves a path against the current working directory.
func AbsPath(path string) string {
if path == "" {
return CurrentDir()
}
if core.PathIsAbs(path) {
return core.CleanPath(path, DirSep())
}
return JoinPath(CurrentDir(), path)
}
// MkdirTemp creates a temporary directory with a deterministic Core-generated name.
func MkdirTemp(prefix string) (string, error) {
name := prefix
if name == "" {
name = "tmp-"
}
path := JoinPath(TempDir(), core.Concat(name, core.ID()))
if err := coreio.Local.EnsureDir(path); err != nil {
return "", err
}
return path, nil
}

View file

@ -1,401 +0,0 @@
package proc
import (
"context"
goio "io"
"sync"
"syscall"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
"dappco.re/go/core/container/internal/coreutil"
)
type fdProvider interface {
Fd() uintptr
}
type Process struct {
Pid int
}
func (p *Process) Kill() error {
if p == nil || p.Pid <= 0 {
return nil
}
return syscall.Kill(p.Pid, syscall.SIGKILL)
}
func (p *Process) Signal(sig syscall.Signal) error {
if p == nil || p.Pid <= 0 {
return nil
}
return syscall.Kill(p.Pid, sig)
}
type Command struct {
Path string
Args []string
Dir string
Env []string
Stdin goio.Reader
Stdout goio.Writer
Stderr goio.Writer
Process *Process
ctx context.Context
started bool
done chan struct{}
waitErr error
waited bool
waitMu sync.Mutex
stdoutPipe *pipeReader
stderrPipe *pipeReader
}
type pipeReader struct {
fd int
childFD int
}
func (p *pipeReader) Read(data []byte) (int, error) {
n, err := syscall.Read(p.fd, data)
if err != nil {
return n, err
}
if n == 0 {
return 0, goio.EOF
}
return n, nil
}
func (p *pipeReader) Close() error {
var first error
if p.fd >= 0 {
if err := syscall.Close(p.fd); err != nil {
first = err
}
p.fd = -1
}
if p.childFD >= 0 {
if err := syscall.Close(p.childFD); err != nil && first == nil {
first = err
}
p.childFD = -1
}
return first
}
type stdioReader struct {
fd int
}
func (s *stdioReader) Read(data []byte) (int, error) {
n, err := syscall.Read(s.fd, data)
if err != nil {
return n, err
}
if n == 0 {
return 0, goio.EOF
}
return n, nil
}
func (s *stdioReader) Close() error { return nil }
func (s *stdioReader) Fd() uintptr { return uintptr(s.fd) }
type stdioWriter struct {
fd int
}
func (s *stdioWriter) Write(data []byte) (int, error) {
total := 0
for len(data) > 0 {
n, err := syscall.Write(s.fd, data)
total += n
if err != nil {
return total, err
}
data = data[n:]
}
return total, nil
}
func (s *stdioWriter) Close() error { return nil }
func (s *stdioWriter) Fd() uintptr { return uintptr(s.fd) }
var (
Stdin goio.ReadCloser = &stdioReader{fd: 0}
Stdout goio.WriteCloser = &stdioWriter{fd: 1}
Stderr goio.WriteCloser = &stdioWriter{fd: 2}
)
var (
nullFD int
nullOnce sync.Once
nullErr error
)
func Environ() []string {
return syscall.Environ()
}
func NewCommandContext(ctx context.Context, name string, args ...string) *Command {
if ctx == nil {
ctx = context.Background()
}
return &Command{
Path: name,
Args: append([]string{name}, args...),
ctx: ctx,
}
}
func NewCommand(name string, args ...string) *Command {
return NewCommandContext(context.Background(), name, args...)
}
func LookPath(name string) (string, error) {
if name == "" {
return "", core.E("proc.LookPath", "empty command", nil)
}
if core.Contains(name, "/") || core.Contains(name, "\\") {
if isExecutable(name) {
return name, nil
}
return "", core.E("proc.LookPath", core.Concat("executable not found: ", name), nil)
}
pathEnv := core.Env("PATH")
sep := core.Env("PS")
if sep == "" {
sep = ":"
}
for _, dir := range core.Split(pathEnv, sep) {
if dir == "" {
dir = "."
}
candidate := coreutil.JoinPath(dir, name)
if isExecutable(candidate) {
return candidate, nil
}
}
return "", core.E("proc.LookPath", core.Concat("executable not found: ", name), nil)
}
func (c *Command) StdoutPipe() (goio.ReadCloser, error) {
if c.started {
return nil, core.E("proc.Command.StdoutPipe", "command already started", nil)
}
if c.stdoutPipe != nil {
return nil, core.E("proc.Command.StdoutPipe", "stdout pipe already requested", nil)
}
fds := make([]int, 2)
if err := syscall.Pipe(fds); err != nil {
return nil, err
}
c.stdoutPipe = &pipeReader{fd: fds[0], childFD: fds[1]}
return c.stdoutPipe, nil
}
func (c *Command) StderrPipe() (goio.ReadCloser, error) {
if c.started {
return nil, core.E("proc.Command.StderrPipe", "command already started", nil)
}
if c.stderrPipe != nil {
return nil, core.E("proc.Command.StderrPipe", "stderr pipe already requested", nil)
}
fds := make([]int, 2)
if err := syscall.Pipe(fds); err != nil {
return nil, err
}
c.stderrPipe = &pipeReader{fd: fds[0], childFD: fds[1]}
return c.stderrPipe, nil
}
func (c *Command) Start() error {
if c.started {
return core.E("proc.Command.Start", "command already started", nil)
}
if c.ctx != nil {
if err := c.ctx.Err(); err != nil {
return err
}
}
path, err := LookPath(c.Path)
if err != nil {
return err
}
files := []uintptr{
c.inputFD(),
c.outputFD(c.stdoutPipe, c.Stdout),
c.outputFD(c.stderrPipe, c.Stderr),
}
env := c.Env
if env == nil {
env = Environ()
}
pid, _, err := syscall.StartProcess(path, c.Args, &syscall.ProcAttr{
Dir: c.Dir,
Env: env,
Files: files,
})
if err != nil {
return err
}
c.Process = &Process{Pid: pid}
c.done = make(chan struct{})
c.started = true
c.closeChildPipeEnds()
c.watchContext()
return nil
}
func (c *Command) Run() error {
if err := c.Start(); err != nil {
return err
}
return c.Wait()
}
func (c *Command) Output() ([]byte, error) {
if c.Stdout != nil {
return nil, core.E("proc.Command.Output", "stdout already configured", nil)
}
reader, err := c.StdoutPipe()
if err != nil {
return nil, err
}
defer func() { _ = reader.Close() }()
if err := c.Start(); err != nil {
return nil, err
}
data, readErr := goio.ReadAll(reader)
waitErr := c.Wait()
if readErr != nil {
return nil, readErr
}
if waitErr != nil {
return data, waitErr
}
return data, nil
}
func (c *Command) Wait() error {
c.waitMu.Lock()
defer c.waitMu.Unlock()
if !c.started {
return core.E("proc.Command.Wait", "command not started", nil)
}
if c.waited {
return c.waitErr
}
var status syscall.WaitStatus
for {
_, err := syscall.Wait4(c.Process.Pid, &status, 0, nil)
if err == syscall.EINTR {
continue
}
if err != nil {
c.waitErr = err
break
}
if status.Exited() && status.ExitStatus() != 0 {
c.waitErr = core.E("proc.Command.Wait", core.Sprintf("exit status %d", status.ExitStatus()), nil)
}
if status.Signaled() {
c.waitErr = core.E("proc.Command.Wait", core.Sprintf("signal %d", status.Signal()), nil)
}
break
}
c.waited = true
close(c.done)
return c.waitErr
}
func (c *Command) inputFD() uintptr {
if c.Stdin == nil {
return uintptr(openNull())
}
if file, ok := c.Stdin.(fdProvider); ok {
return file.Fd()
}
return uintptr(openNull())
}
func (c *Command) outputFD(pipe *pipeReader, writer goio.Writer) uintptr {
if pipe != nil {
return uintptr(pipe.childFD)
}
if writer == nil {
return uintptr(openNull())
}
if file, ok := writer.(fdProvider); ok {
return file.Fd()
}
return uintptr(openNull())
}
func (c *Command) closeChildPipeEnds() {
if c.stdoutPipe != nil && c.stdoutPipe.childFD >= 0 {
_ = syscall.Close(c.stdoutPipe.childFD)
c.stdoutPipe.childFD = -1
}
if c.stderrPipe != nil && c.stderrPipe.childFD >= 0 {
_ = syscall.Close(c.stderrPipe.childFD)
c.stderrPipe.childFD = -1
}
}
func (c *Command) watchContext() {
if c.ctx == nil || c.done == nil || c.Process == nil {
return
}
go func() {
select {
case <-c.ctx.Done():
_ = c.Process.Kill()
case <-c.done:
}
}()
}
func isExecutable(path string) bool {
info, err := coreio.Local.Stat(path)
if err != nil {
return false
}
if !info.Mode().IsRegular() {
return false
}
return info.Mode()&0111 != 0
}
func openNull() int {
nullOnce.Do(func() {
nullFD, nullErr = syscall.Open("/dev/null", syscall.O_RDWR, 0)
})
if nullErr != nil {
return 2
}
return nullFD
}

View file

@ -1,6 +1,6 @@
# go-container # go-container
Module: `dappco.re/go/core/container` Module: `forge.lthn.ai/core/go-container`
Container runtime for managing LinuxKit VMs as lightweight containers. Supports running LinuxKit images (ISO, qcow2, vmdk, raw) via QEMU or Hyperkit hypervisors. Includes a dev environment system for Claude Code agents and development workflows. Container runtime for managing LinuxKit VMs as lightweight containers. Supports running LinuxKit images (ISO, qcow2, vmdk, raw) via QEMU or Hyperkit hypervisors. Includes a dev environment system for Claude Code agents and development workflows.
@ -55,7 +55,7 @@ Container runtime for managing LinuxKit VMs as lightweight containers. Supports
## Usage ## Usage
```go ```go
import "dappco.re/go/core/container" import "forge.lthn.ai/core/go-container"
// Auto-detect hypervisor // Auto-detect hypervisor
hv, _ := container.DetectHypervisor() hv, _ := container.DetectHypervisor()

View file

@ -1,6 +1,6 @@
# Hypervisors # Hypervisors
Module: `dappco.re/go/core/container` Module: `forge.lthn.ai/core/go-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"
core "dappco.re/go/core" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/proc"
) )
// LinuxKitManager implements the Manager interface for LinuxKit VMs. // LinuxKitManager implements the Manager interface for LinuxKit VMs.
@ -22,10 +22,6 @@ type LinuxKitManager struct {
} }
// NewLinuxKitManager creates a new LinuxKit manager with auto-detected hypervisor. // NewLinuxKitManager creates a new LinuxKit manager with auto-detected hypervisor.
//
// Usage:
//
// manager, err := NewLinuxKitManager(io.Local)
func NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error) { func NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error) {
statePath, err := DefaultStatePath() statePath, err := DefaultStatePath()
if err != nil { if err != nil {
@ -50,10 +46,6 @@ func NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error) {
} }
// NewLinuxKitManagerWithHypervisor creates a manager with a specific hypervisor. // NewLinuxKitManagerWithHypervisor creates a manager with a specific hypervisor.
//
// Usage:
//
// manager := NewLinuxKitManagerWithHypervisor(io.Local, state, hypervisor)
func NewLinuxKitManagerWithHypervisor(m io.Medium, state *State, hypervisor Hypervisor) *LinuxKitManager { func NewLinuxKitManagerWithHypervisor(m io.Medium, state *State, hypervisor Hypervisor) *LinuxKitManager {
return &LinuxKitManager{ return &LinuxKitManager{
state: state, state: state,
@ -127,7 +119,7 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
} }
// Create log file // Create log file
logFile, err := io.Local.Create(logPath) logFile, err := os.Create(logPath)
if err != nil { if err != nil {
return nil, coreerr.E("LinuxKitManager.Run", "failed to create log file", err) return nil, coreerr.E("LinuxKitManager.Run", "failed to create log file", err)
} }
@ -204,11 +196,11 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
// Copy output to both log and stdout // Copy output to both log and stdout
go func() { go func() {
mw := goio.MultiWriter(logFile, proc.Stdout) mw := goio.MultiWriter(logFile, os.Stdout)
_, _ = goio.Copy(mw, stdout) _, _ = goio.Copy(mw, stdout)
}() }()
go func() { go func() {
mw := goio.MultiWriter(logFile, proc.Stderr) mw := goio.MultiWriter(logFile, os.Stderr)
_, _ = goio.Copy(mw, stderr) _, _ = goio.Copy(mw, stderr)
}() }()
@ -228,7 +220,7 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
} }
// waitForExit monitors a detached process and updates state when it exits. // waitForExit monitors a detached process and updates state when it exits.
func (m *LinuxKitManager) waitForExit(id string, cmd *proc.Command) { func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) {
err := cmd.Wait() err := cmd.Wait()
container, ok := m.state.Get(id) container, ok := m.state.Get(id)
@ -257,7 +249,16 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
} }
// Find the process // Find the process
if err := syscall.Kill(container.PID, syscall.SIGTERM); err != nil { process, err := os.FindProcess(container.PID)
if err != nil {
// Process doesn't exist, update state
container.Status = StatusStopped
_ = m.state.Update(container)
return nil
}
// Send SIGTERM
if err := process.Signal(syscall.SIGTERM); err != nil {
// Process might already be gone // Process might already be gone
container.Status = StatusStopped container.Status = StatusStopped
_ = m.state.Update(container) _ = m.state.Update(container)
@ -266,23 +267,28 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
// Honour already-cancelled contexts before waiting // Honour already-cancelled contexts before waiting
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
_ = syscall.Kill(container.PID, syscall.SIGKILL) _ = process.Signal(syscall.SIGKILL)
return err return err
} }
deadline := time.After(10 * time.Second) // Wait for graceful shutdown with timeout
ticker := time.NewTicker(100 * time.Millisecond) done := make(chan struct{})
defer ticker.Stop() go func() {
_, _ = process.Wait()
close(done)
}()
for isProcessRunning(container.PID) { select {
select { case <-done:
case <-deadline: // Process exited gracefully
_ = syscall.Kill(container.PID, syscall.SIGKILL) case <-time.After(10 * time.Second):
case <-ctx.Done(): // Force kill
_ = syscall.Kill(container.PID, syscall.SIGKILL) _ = process.Signal(syscall.SIGKILL)
return ctx.Err() <-done
case <-ticker.C: case <-ctx.Done():
} // Context cancelled
_ = process.Signal(syscall.SIGKILL)
return ctx.Err()
} }
container.Status = StatusStopped container.Status = StatusStopped
@ -311,10 +317,14 @@ func (m *LinuxKitManager) List(ctx context.Context) ([]*Container, error) {
// isProcessRunning checks if a process with the given PID is still running. // isProcessRunning checks if a process with the given PID is still running.
func isProcessRunning(pid int) bool { func isProcessRunning(pid int) bool {
if pid <= 0 { process, err := os.FindProcess(pid)
if err != nil {
return false return false
} }
return syscall.Kill(pid, syscall.Signal(0)) == nil
// On Unix, FindProcess always succeeds, so we need to send signal 0 to check
err = process.Signal(syscall.Signal(0))
return err == nil
} }
// Logs returns a reader for the container's log output. // Logs returns a reader for the container's log output.
@ -426,7 +436,7 @@ func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) err
// Build SSH command // Build SSH command
sshArgs := []string{ sshArgs := []string{
"-p", core.Sprintf("%d", sshPort), "-p", fmt.Sprintf("%d", sshPort),
"-o", "StrictHostKeyChecking=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "UserKnownHostsFile=~/.core/known_hosts",
"-o", "LogLevel=ERROR", "-o", "LogLevel=ERROR",
@ -434,10 +444,10 @@ func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) err
} }
sshArgs = append(sshArgs, cmd...) sshArgs = append(sshArgs, cmd...)
sshCmd := proc.NewCommandContext(ctx, "ssh", sshArgs...) sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...)
sshCmd.Stdin = proc.Stdin sshCmd.Stdin = os.Stdin
sshCmd.Stdout = proc.Stdout sshCmd.Stdout = os.Stdout
sshCmd.Stderr = proc.Stderr sshCmd.Stderr = os.Stderr
return sshCmd.Run() return sshCmd.Run()
} }

View file

@ -2,14 +2,13 @@ package container
import ( import (
"context" "context"
"syscall" "os"
"os/exec"
"path/filepath"
"testing" "testing"
"time" "time"
core "dappco.re/go/core" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/container/internal/coreutil"
"dappco.re/go/core/container/internal/proc"
"dappco.re/go/core/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -40,30 +39,30 @@ func (m *MockHypervisor) Available() bool {
return m.available return m.available
} }
func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) { func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) {
m.lastImage = image m.lastImage = image
m.lastOpts = opts m.lastOpts = opts
if m.buildErr != nil { if m.buildErr != nil {
return nil, m.buildErr return nil, m.buildErr
} }
// Return a simple command that exits quickly // Return a simple command that exits quickly
return proc.NewCommandContext(ctx, m.commandToRun, "test"), nil return exec.CommandContext(ctx, m.commandToRun, "test"), nil
} }
// newTestManager creates a LinuxKitManager with mock hypervisor for testing. // newTestManager creates a LinuxKitManager with mock hypervisor for testing.
// Uses manual temp directory management to avoid race conditions with t.TempDir cleanup. // Uses manual temp directory management to avoid race conditions with t.TempDir cleanup.
func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) { func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) {
tmpDir, err := coreutil.MkdirTemp("linuxkit-test-") tmpDir, err := os.MkdirTemp("", "linuxkit-test-*")
require.NoError(t, err) require.NoError(t, err)
// Manual cleanup that handles race conditions with state file writes // Manual cleanup that handles race conditions with state file writes
t.Cleanup(func() { t.Cleanup(func() {
// Give any pending file operations time to complete // Give any pending file operations time to complete
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
_ = io.Local.DeleteAll(tmpDir) _ = os.RemoveAll(tmpDir)
}) })
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
state, err := LoadState(statePath) state, err := LoadState(statePath)
require.NoError(t, err) require.NoError(t, err)
@ -74,9 +73,9 @@ func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) {
return manager, mock, tmpDir return manager, mock, tmpDir
} }
func TestLinuxKit_NewLinuxKitManagerWithHypervisor_Good(t *testing.T) { func TestNewLinuxKitManagerWithHypervisor_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
state, _ := LoadState(statePath) state, _ := LoadState(statePath)
mock := NewMockHypervisor() mock := NewMockHypervisor()
@ -87,12 +86,12 @@ func TestLinuxKit_NewLinuxKitManagerWithHypervisor_Good(t *testing.T) {
assert.Equal(t, mock, manager.Hypervisor()) assert.Equal(t, mock, manager.Hypervisor())
} }
func TestLinuxKitManager_Run_Detached_Good(t *testing.T) { func TestLinuxKitManager_Run_Good_Detached(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
// Create a test image file // Create a test image file
imagePath := coreutil.JoinPath(tmpDir, "test.iso") imagePath := filepath.Join(tmpDir, "test.iso")
err := io.Local.Write(imagePath, "fake image") err := os.WriteFile(imagePath, []byte("fake image"), 0644)
require.NoError(t, err) require.NoError(t, err)
// Use a command that runs briefly then exits // Use a command that runs briefly then exits
@ -126,11 +125,11 @@ func TestLinuxKitManager_Run_Detached_Good(t *testing.T) {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
func TestLinuxKitManager_Run_DefaultValues_Good(t *testing.T) { func TestLinuxKitManager_Run_Good_DefaultValues(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
imagePath := coreutil.JoinPath(tmpDir, "test.qcow2") imagePath := filepath.Join(tmpDir, "test.qcow2")
err := io.Local.Write(imagePath, "fake image") err := os.WriteFile(imagePath, []byte("fake image"), 0644)
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
@ -151,7 +150,7 @@ func TestLinuxKitManager_Run_DefaultValues_Good(t *testing.T) {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
} }
func TestLinuxKitManager_Run_ImageNotFound_Bad(t *testing.T) { func TestLinuxKitManager_Run_Bad_ImageNotFound(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
ctx := context.Background() ctx := context.Background()
@ -162,11 +161,11 @@ func TestLinuxKitManager_Run_ImageNotFound_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "image not found") assert.Contains(t, err.Error(), "image not found")
} }
func TestLinuxKitManager_Run_UnsupportedFormat_Bad(t *testing.T) { func TestLinuxKitManager_Run_Bad_UnsupportedFormat(t *testing.T) {
manager, _, tmpDir := newTestManager(t) manager, _, tmpDir := newTestManager(t)
imagePath := coreutil.JoinPath(tmpDir, "test.txt") imagePath := filepath.Join(tmpDir, "test.txt")
err := io.Local.Write(imagePath, "not an image") err := os.WriteFile(imagePath, []byte("not an image"), 0644)
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
@ -202,7 +201,7 @@ func TestLinuxKitManager_Stop_Good(t *testing.T) {
assert.Equal(t, StatusStopped, c.Status) assert.Equal(t, StatusStopped, c.Status)
} }
func TestLinuxKitManager_Stop_NotFound_Bad(t *testing.T) { func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
ctx := context.Background() ctx := context.Background()
@ -212,9 +211,9 @@ func TestLinuxKitManager_Stop_NotFound_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "container not found") assert.Contains(t, err.Error(), "container not found")
} }
func TestLinuxKitManager_Stop_NotRunning_Bad(t *testing.T) { func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) {
_, _, tmpDir := newTestManager(t) _, _, tmpDir := newTestManager(t)
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
state, err := LoadState(statePath) state, err := LoadState(statePath)
require.NoError(t, err) require.NoError(t, err)
manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor())
@ -234,7 +233,7 @@ func TestLinuxKitManager_Stop_NotRunning_Bad(t *testing.T) {
func TestLinuxKitManager_List_Good(t *testing.T) { func TestLinuxKitManager_List_Good(t *testing.T) {
_, _, tmpDir := newTestManager(t) _, _, tmpDir := newTestManager(t)
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
state, err := LoadState(statePath) state, err := LoadState(statePath)
require.NoError(t, err) require.NoError(t, err)
manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor())
@ -249,9 +248,9 @@ func TestLinuxKitManager_List_Good(t *testing.T) {
assert.Len(t, containers, 2) assert.Len(t, containers, 2)
} }
func TestLinuxKitManager_List_VerifiesRunningStatus_Good(t *testing.T) { func TestLinuxKitManager_List_Good_VerifiesRunningStatus(t *testing.T) {
_, _, tmpDir := newTestManager(t) _, _, tmpDir := newTestManager(t)
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
state, err := LoadState(statePath) state, err := LoadState(statePath)
require.NoError(t, err) require.NoError(t, err)
manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor())
@ -276,8 +275,8 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) {
manager, _, tmpDir := newTestManager(t) manager, _, tmpDir := newTestManager(t)
// Create a log file manually // Create a log file manually
logsDir := coreutil.JoinPath(tmpDir, "logs") logsDir := filepath.Join(tmpDir, "logs")
require.NoError(t, io.Local.EnsureDir(logsDir)) require.NoError(t, os.MkdirAll(logsDir, 0755))
container := &Container{ID: "abc12345"} container := &Container{ID: "abc12345"}
_ = manager.State().Add(container) _ = manager.State().Add(container)
@ -287,8 +286,8 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) {
logContent := "test log content\nline 2\n" logContent := "test log content\nline 2\n"
logPath, err := LogPath("abc12345") logPath, err := LogPath("abc12345")
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, io.Local.EnsureDir(core.PathDir(logPath))) require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755))
require.NoError(t, io.Local.Write(logPath, logContent)) require.NoError(t, os.WriteFile(logPath, []byte(logContent), 0644))
ctx := context.Background() ctx := context.Background()
reader, err := manager.Logs(ctx, "abc12345", false) reader, err := manager.Logs(ctx, "abc12345", false)
@ -301,7 +300,7 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) {
assert.Equal(t, logContent, string(buf[:n])) assert.Equal(t, logContent, string(buf[:n]))
} }
func TestLinuxKitManager_Logs_NotFound_Bad(t *testing.T) { func TestLinuxKitManager_Logs_Bad_NotFound(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
ctx := context.Background() ctx := context.Background()
@ -311,7 +310,7 @@ func TestLinuxKitManager_Logs_NotFound_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "container not found") assert.Contains(t, err.Error(), "container not found")
} }
func TestLinuxKitManager_Logs_NoLogFile_Bad(t *testing.T) { func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
// Use a unique ID that won't have a log file // Use a unique ID that won't have a log file
@ -334,7 +333,7 @@ func TestLinuxKitManager_Logs_NoLogFile_Bad(t *testing.T) {
} }
} }
func TestLinuxKitManager_Exec_NotFound_Bad(t *testing.T) { func TestLinuxKitManager_Exec_Bad_NotFound(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
ctx := context.Background() ctx := context.Background()
@ -344,7 +343,7 @@ func TestLinuxKitManager_Exec_NotFound_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "container not found") assert.Contains(t, err.Error(), "container not found")
} }
func TestLinuxKitManager_Exec_NotRunning_Bad(t *testing.T) { func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
container := &Container{ID: "abc12345", Status: StatusStopped} container := &Container{ID: "abc12345", Status: StatusStopped}
@ -357,7 +356,7 @@ func TestLinuxKitManager_Exec_NotRunning_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "not running") assert.Contains(t, err.Error(), "not running")
} }
func TestLinuxKit_DetectImageFormat_Good(t *testing.T) { func TestDetectImageFormat_Good(t *testing.T) {
tests := []struct { tests := []struct {
path string path string
format ImageFormat format ImageFormat
@ -379,7 +378,7 @@ func TestLinuxKit_DetectImageFormat_Good(t *testing.T) {
} }
} }
func TestDetectImageFormat_Unknown_Bad(t *testing.T) { func TestDetectImageFormat_Bad_Unknown(t *testing.T) {
tests := []string{ tests := []string{
"/path/to/image.txt", "/path/to/image.txt",
"/path/to/image", "/path/to/image",
@ -427,7 +426,7 @@ func TestQemuHypervisor_BuildCommand_Good(t *testing.T) {
assert.Contains(t, args, "-nographic") assert.Contains(t, args, "-nographic")
} }
func TestLinuxKitManager_Logs_Follow_Good(t *testing.T) { func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
// Create a unique container ID // Create a unique container ID
@ -439,10 +438,10 @@ func TestLinuxKitManager_Logs_Follow_Good(t *testing.T) {
// Create a log file at the expected location // Create a log file at the expected location
logPath, err := LogPath(uniqueID) logPath, err := LogPath(uniqueID)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, io.Local.EnsureDir(core.PathDir(logPath))) require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755))
// Write initial content // Write initial content
err = io.Local.Write(logPath, "initial log content\n") err = os.WriteFile(logPath, []byte("initial log content\n"), 0644)
require.NoError(t, err) require.NoError(t, err)
// Create a cancellable context // Create a cancellable context
@ -465,13 +464,13 @@ func TestLinuxKitManager_Logs_Follow_Good(t *testing.T) {
assert.NoError(t, reader.Close()) assert.NoError(t, reader.Close())
} }
func TestFollowReader_Read_WithData_Good(t *testing.T) { func TestFollowReader_Read_Good_WithData(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
logPath := coreutil.JoinPath(tmpDir, "test.log") logPath := filepath.Join(tmpDir, "test.log")
// Create log file with content // Create log file with content
content := "test log line 1\ntest log line 2\n" content := "test log line 1\ntest log line 2\n"
err := io.Local.Write(logPath, content) err := os.WriteFile(logPath, []byte(content), 0644)
require.NoError(t, err) require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@ -482,9 +481,9 @@ func TestFollowReader_Read_WithData_Good(t *testing.T) {
defer func() { _ = reader.Close() }() defer func() { _ = reader.Close() }()
// The followReader seeks to end, so we need to append more content // The followReader seeks to end, so we need to append more content
f, err := io.Local.Append(logPath) f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0644)
require.NoError(t, err) require.NoError(t, err)
_, err = f.Write([]byte("new line\n")) _, err = f.WriteString("new line\n")
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, f.Close()) require.NoError(t, f.Close())
@ -498,12 +497,12 @@ func TestFollowReader_Read_WithData_Good(t *testing.T) {
} }
} }
func TestFollowReader_Read_ContextCancel_Good(t *testing.T) { func TestFollowReader_Read_Good_ContextCancel(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
logPath := coreutil.JoinPath(tmpDir, "test.log") logPath := filepath.Join(tmpDir, "test.log")
// Create log file // Create log file
err := io.Local.Write(logPath, "initial content\n") err := os.WriteFile(logPath, []byte("initial content\n"), 0644)
require.NoError(t, err) require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@ -524,9 +523,9 @@ func TestFollowReader_Read_ContextCancel_Good(t *testing.T) {
func TestFollowReader_Close_Good(t *testing.T) { func TestFollowReader_Close_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
logPath := coreutil.JoinPath(tmpDir, "test.log") logPath := filepath.Join(tmpDir, "test.log")
err := io.Local.Write(logPath, "content\n") err := os.WriteFile(logPath, []byte("content\n"), 0644)
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
@ -542,19 +541,19 @@ func TestFollowReader_Close_Good(t *testing.T) {
assert.Error(t, readErr) assert.Error(t, readErr)
} }
func TestNewFollowReader_FileNotFound_Bad(t *testing.T) { func TestNewFollowReader_Bad_FileNotFound(t *testing.T) {
ctx := context.Background() ctx := context.Background()
_, err := newFollowReader(ctx, io.Local, "/nonexistent/path/to/file.log") _, err := newFollowReader(ctx, io.Local, "/nonexistent/path/to/file.log")
assert.Error(t, err) assert.Error(t, err)
} }
func TestLinuxKitManager_Run_BuildCommandError_Bad(t *testing.T) { func TestLinuxKitManager_Run_Bad_BuildCommandError(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
// Create a test image file // Create a test image file
imagePath := coreutil.JoinPath(tmpDir, "test.iso") imagePath := filepath.Join(tmpDir, "test.iso")
err := io.Local.Write(imagePath, "fake image") err := os.WriteFile(imagePath, []byte("fake image"), 0644)
require.NoError(t, err) require.NoError(t, err)
// Configure mock to return an error // Configure mock to return an error
@ -568,12 +567,12 @@ func TestLinuxKitManager_Run_BuildCommandError_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "failed to build hypervisor command") assert.Contains(t, err.Error(), "failed to build hypervisor command")
} }
func TestLinuxKitManager_Run_Foreground_Good(t *testing.T) { func TestLinuxKitManager_Run_Good_Foreground(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
// Create a test image file // Create a test image file
imagePath := coreutil.JoinPath(tmpDir, "test.iso") imagePath := filepath.Join(tmpDir, "test.iso")
err := io.Local.Write(imagePath, "fake image") err := os.WriteFile(imagePath, []byte("fake image"), 0644)
require.NoError(t, err) require.NoError(t, err)
// Use echo which exits quickly // Use echo which exits quickly
@ -596,12 +595,12 @@ func TestLinuxKitManager_Run_Foreground_Good(t *testing.T) {
assert.Equal(t, StatusStopped, container.Status) assert.Equal(t, StatusStopped, container.Status)
} }
func TestLinuxKitManager_Stop_ContextCancelled_Good(t *testing.T) { func TestLinuxKitManager_Stop_Good_ContextCancelled(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
// Create a test image file // Create a test image file
imagePath := coreutil.JoinPath(tmpDir, "test.iso") imagePath := filepath.Join(tmpDir, "test.iso")
err := io.Local.Write(imagePath, "fake image") err := os.WriteFile(imagePath, []byte("fake image"), 0644)
require.NoError(t, err) require.NoError(t, err)
// Use a command that takes a long time // Use a command that takes a long time
@ -633,23 +632,23 @@ func TestLinuxKitManager_Stop_ContextCancelled_Good(t *testing.T) {
assert.Equal(t, context.Canceled, err) assert.Equal(t, context.Canceled, err)
} }
func TestIsProcessRunning_ExistingProcess_Good(t *testing.T) { func TestIsProcessRunning_Good_ExistingProcess(t *testing.T) {
// Use our own PID which definitely exists // Use our own PID which definitely exists
running := isProcessRunning(syscall.Getpid()) running := isProcessRunning(os.Getpid())
assert.True(t, running) assert.True(t, running)
} }
func TestIsProcessRunning_NonexistentProcess_Bad(t *testing.T) { func TestIsProcessRunning_Bad_NonexistentProcess(t *testing.T) {
// Use a PID that almost certainly doesn't exist // Use a PID that almost certainly doesn't exist
running := isProcessRunning(999999) running := isProcessRunning(999999)
assert.False(t, running) assert.False(t, running)
} }
func TestLinuxKitManager_Run_WithPortsAndVolumes_Good(t *testing.T) { func TestLinuxKitManager_Run_Good_WithPortsAndVolumes(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
imagePath := coreutil.JoinPath(tmpDir, "test.iso") imagePath := filepath.Join(tmpDir, "test.iso")
err := io.Local.Write(imagePath, "fake image") err := os.WriteFile(imagePath, []byte("fake image"), 0644)
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
@ -674,12 +673,12 @@ func TestLinuxKitManager_Run_WithPortsAndVolumes_Good(t *testing.T) {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
} }
func TestFollowReader_Read_ReaderError_Bad(t *testing.T) { func TestFollowReader_Read_Bad_ReaderError(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
logPath := coreutil.JoinPath(tmpDir, "test.log") logPath := filepath.Join(tmpDir, "test.log")
// Create log file // Create log file
err := io.Local.Write(logPath, "content\n") err := os.WriteFile(logPath, []byte("content\n"), 0644)
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
@ -695,11 +694,11 @@ func TestFollowReader_Read_ReaderError_Bad(t *testing.T) {
assert.Error(t, readErr) assert.Error(t, readErr)
} }
func TestLinuxKitManager_Run_StartError_Bad(t *testing.T) { func TestLinuxKitManager_Run_Bad_StartError(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
imagePath := coreutil.JoinPath(tmpDir, "test.iso") imagePath := filepath.Join(tmpDir, "test.iso")
err := io.Local.Write(imagePath, "fake image") err := os.WriteFile(imagePath, []byte("fake image"), 0644)
require.NoError(t, err) require.NoError(t, err)
// Use a command that doesn't exist to cause Start() to fail // Use a command that doesn't exist to cause Start() to fail
@ -716,11 +715,11 @@ func TestLinuxKitManager_Run_StartError_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "failed to start VM") assert.Contains(t, err.Error(), "failed to start VM")
} }
func TestLinuxKitManager_Run_ForegroundStartError_Bad(t *testing.T) { func TestLinuxKitManager_Run_Bad_ForegroundStartError(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
imagePath := coreutil.JoinPath(tmpDir, "test.iso") imagePath := filepath.Join(tmpDir, "test.iso")
err := io.Local.Write(imagePath, "fake image") err := os.WriteFile(imagePath, []byte("fake image"), 0644)
require.NoError(t, err) require.NoError(t, err)
// Use a command that doesn't exist to cause Start() to fail // Use a command that doesn't exist to cause Start() to fail
@ -737,11 +736,11 @@ func TestLinuxKitManager_Run_ForegroundStartError_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "failed to start VM") assert.Contains(t, err.Error(), "failed to start VM")
} }
func TestLinuxKitManager_Run_ForegroundWithError_Good(t *testing.T) { func TestLinuxKitManager_Run_Good_ForegroundWithError(t *testing.T) {
manager, mock, tmpDir := newTestManager(t) manager, mock, tmpDir := newTestManager(t)
imagePath := coreutil.JoinPath(tmpDir, "test.iso") imagePath := filepath.Join(tmpDir, "test.iso")
err := io.Local.Write(imagePath, "fake image") err := os.WriteFile(imagePath, []byte("fake image"), 0644)
require.NoError(t, err) require.NoError(t, err)
// Use a command that exits with error // Use a command that exits with error
@ -760,7 +759,7 @@ func TestLinuxKitManager_Run_ForegroundWithError_Good(t *testing.T) {
assert.Equal(t, StatusError, container.Status) assert.Equal(t, StatusError, container.Status)
} }
func TestLinuxKitManager_Stop_ProcessExitedWhileRunning_Good(t *testing.T) { func TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning(t *testing.T) {
manager, _, _ := newTestManager(t) manager, _, _ := newTestManager(t)
// Add a "running" container with a process that has already exited // Add a "running" container with a process that has already exited

View file

@ -2,14 +2,14 @@ package sources
import ( import (
"context" "context"
"fmt"
goio "io" goio "io"
"net/http" "net/http"
"os"
"path/filepath"
core "dappco.re/go/core" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/coreutil"
) )
// CDNSource downloads images from a CDN or S3 bucket. // CDNSource downloads images from a CDN or S3 bucket.
@ -21,10 +21,6 @@ type CDNSource struct {
var _ ImageSource = (*CDNSource)(nil) var _ ImageSource = (*CDNSource)(nil)
// NewCDNSource creates a new CDN source. // NewCDNSource creates a new CDN source.
//
// Usage:
//
// src := NewCDNSource(cfg)
func NewCDNSource(cfg SourceConfig) *CDNSource { func NewCDNSource(cfg SourceConfig) *CDNSource {
return &CDNSource{config: cfg} return &CDNSource{config: cfg}
} }
@ -42,7 +38,7 @@ func (s *CDNSource) Available() bool {
// LatestVersion fetches version from manifest or returns "latest". // LatestVersion fetches version from manifest or returns "latest".
func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) { func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) {
// Try to fetch manifest.json for version info // Try to fetch manifest.json for version info
url := core.Sprintf("%s/manifest.json", s.config.CDNURL) url := fmt.Sprintf("%s/manifest.json", s.config.CDNURL)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return "latest", nil return "latest", nil
@ -60,7 +56,7 @@ func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) {
// Download downloads the image from CDN. // Download downloads the image from CDN.
func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error { func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error {
url := core.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName) url := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
@ -74,7 +70,7 @@ func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, prog
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return coreerr.E("cdn.Download", core.Sprintf("HTTP %d", resp.StatusCode), nil) return coreerr.E("cdn.Download", fmt.Sprintf("HTTP %d", resp.StatusCode), nil)
} }
// Ensure dest directory exists // Ensure dest directory exists
@ -83,8 +79,8 @@ func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, prog
} }
// Create destination file // Create destination file
destPath := coreutil.JoinPath(dest, s.config.ImageName) destPath := filepath.Join(dest, s.config.ImageName)
f, err := m.Create(destPath) f, err := os.Create(destPath)
if err != nil { if err != nil {
return coreerr.E("cdn.Download", "create destination file", err) return coreerr.E("cdn.Download", "create destination file", err)
} }

View file

@ -2,18 +2,18 @@ package sources
import ( import (
"context" "context"
goio "io" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/container/internal/coreutil"
"dappco.re/go/core/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestCDNSource_Available_Good(t *testing.T) { func TestCDNSource_Good_Available(t *testing.T) {
src := NewCDNSource(SourceConfig{ src := NewCDNSource(SourceConfig{
CDNURL: "https://images.example.com", CDNURL: "https://images.example.com",
ImageName: "core-devops-darwin-arm64.qcow2", ImageName: "core-devops-darwin-arm64.qcow2",
@ -23,7 +23,7 @@ func TestCDNSource_Available_Good(t *testing.T) {
assert.True(t, src.Available()) assert.True(t, src.Available())
} }
func TestCDNSource_NoURL_Bad(t *testing.T) { func TestCDNSource_Bad_NoURL(t *testing.T) {
src := NewCDNSource(SourceConfig{ src := NewCDNSource(SourceConfig{
ImageName: "core-devops-darwin-arm64.qcow2", ImageName: "core-devops-darwin-arm64.qcow2",
}) })
@ -35,7 +35,7 @@ func TestCDNSource_LatestVersion_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/manifest.json" { if r.URL.Path == "/manifest.json" {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = goio.WriteString(w, `{"version": "1.2.3"}`) _, _ = fmt.Fprint(w, `{"version": "1.2.3"}`)
} else { } else {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
@ -57,7 +57,7 @@ func TestCDNSource_Download_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/test.img" { if r.URL.Path == "/test.img" {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = goio.WriteString(w, content) _, _ = fmt.Fprint(w, content)
} else { } else {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
@ -80,9 +80,9 @@ func TestCDNSource_Download_Good(t *testing.T) {
assert.True(t, progressCalled) assert.True(t, progressCalled)
// Verify file content // Verify file content
data, err := io.Local.Read(coreutil.JoinPath(dest, imageName)) data, err := os.ReadFile(filepath.Join(dest, imageName))
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, content, data) assert.Equal(t, content, string(data))
} }
func TestCDNSource_Download_Bad(t *testing.T) { func TestCDNSource_Download_Bad(t *testing.T) {
@ -115,7 +115,7 @@ func TestCDNSource_Download_Bad(t *testing.T) {
}) })
} }
func TestCDNSource_LatestVersion_NoManifest_Bad(t *testing.T) { func TestCDNSource_LatestVersion_Bad_NoManifest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
})) }))
@ -131,7 +131,7 @@ func TestCDNSource_LatestVersion_NoManifest_Bad(t *testing.T) {
assert.Equal(t, "latest", version) assert.Equal(t, "latest", version)
} }
func TestCDNSource_LatestVersion_ServerError_Bad(t *testing.T) { func TestCDNSource_LatestVersion_Bad_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
})) }))
@ -147,12 +147,12 @@ func TestCDNSource_LatestVersion_ServerError_Bad(t *testing.T) {
assert.Equal(t, "latest", version) assert.Equal(t, "latest", version)
} }
func TestCDNSource_Download_NoProgress_Good(t *testing.T) { func TestCDNSource_Download_Good_NoProgress(t *testing.T) {
content := "test content" content := "test content"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", core.Sprintf("%d", len(content))) w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = goio.WriteString(w, content) _, _ = fmt.Fprint(w, content)
})) }))
defer server.Close() defer server.Close()
@ -166,12 +166,12 @@ func TestCDNSource_Download_NoProgress_Good(t *testing.T) {
err := src.Download(context.Background(), io.Local, dest, nil) err := src.Download(context.Background(), io.Local, dest, nil)
assert.NoError(t, err) assert.NoError(t, err)
data, err := io.Local.Read(coreutil.JoinPath(dest, "test.img")) data, err := os.ReadFile(filepath.Join(dest, "test.img"))
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, content, data) assert.Equal(t, content, string(data))
} }
func TestCDNSource_Download_LargeFile_Good(t *testing.T) { func TestCDNSource_Download_Good_LargeFile(t *testing.T) {
// Create content larger than buffer size (32KB) // Create content larger than buffer size (32KB)
content := make([]byte, 64*1024) // 64KB content := make([]byte, 64*1024) // 64KB
for i := range content { for i := range content {
@ -179,7 +179,7 @@ func TestCDNSource_Download_LargeFile_Good(t *testing.T) {
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", core.Sprintf("%d", len(content))) w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(content) _, _ = w.Write(content)
})) }))
@ -203,7 +203,7 @@ func TestCDNSource_Download_LargeFile_Good(t *testing.T) {
assert.Equal(t, int64(len(content)), lastDownloaded) assert.Equal(t, int64(len(content)), lastDownloaded)
} }
func TestCDNSource_Download_HTTPErrorCodes_Bad(t *testing.T) { func TestCDNSource_Download_Bad_HTTPErrorCodes(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
statusCode int statusCode int
@ -230,17 +230,17 @@ func TestCDNSource_Download_HTTPErrorCodes_Bad(t *testing.T) {
err := src.Download(context.Background(), io.Local, dest, nil) err := src.Download(context.Background(), io.Local, dest, nil)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), core.Sprintf("HTTP %d", tc.statusCode)) assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", tc.statusCode))
}) })
} }
} }
func TestCDNSource_InterfaceCompliance_Good(t *testing.T) { func TestCDNSource_InterfaceCompliance(t *testing.T) {
// Verify CDNSource implements ImageSource // Verify CDNSource implements ImageSource
var _ ImageSource = (*CDNSource)(nil) var _ ImageSource = (*CDNSource)(nil)
} }
func TestCDNSource_Config_Good(t *testing.T) { func TestCDNSource_Config(t *testing.T) {
cfg := SourceConfig{ cfg := SourceConfig{
CDNURL: "https://cdn.example.com", CDNURL: "https://cdn.example.com",
ImageName: "my-image.qcow2", ImageName: "my-image.qcow2",
@ -251,7 +251,7 @@ func TestCDNSource_Config_Good(t *testing.T) {
assert.Equal(t, "my-image.qcow2", src.config.ImageName) assert.Equal(t, "my-image.qcow2", src.config.ImageName)
} }
func TestCDN_NewCDNSource_Good(t *testing.T) { func TestNewCDNSource_Good(t *testing.T) {
cfg := SourceConfig{ cfg := SourceConfig{
GitHubRepo: "host-uk/core-images", GitHubRepo: "host-uk/core-images",
RegistryImage: "ghcr.io/host-uk/core-devops", RegistryImage: "ghcr.io/host-uk/core-devops",
@ -265,16 +265,16 @@ func TestCDN_NewCDNSource_Good(t *testing.T) {
assert.Equal(t, cfg.CDNURL, src.config.CDNURL) assert.Equal(t, cfg.CDNURL, src.config.CDNURL)
} }
func TestCDNSource_Download_CreatesDestDir_Good(t *testing.T) { func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) {
content := "test content" content := "test content"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = goio.WriteString(w, content) _, _ = fmt.Fprint(w, content)
})) }))
defer server.Close() defer server.Close()
tmpDir := t.TempDir() tmpDir := t.TempDir()
dest := coreutil.JoinPath(tmpDir, "nested", "dir") dest := filepath.Join(tmpDir, "nested", "dir")
// dest doesn't exist yet // dest doesn't exist yet
src := NewCDNSource(SourceConfig{ src := NewCDNSource(SourceConfig{
@ -286,12 +286,12 @@ func TestCDNSource_Download_CreatesDestDir_Good(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// Verify nested dir was created // Verify nested dir was created
info, err := io.Local.Stat(dest) info, err := os.Stat(dest)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, info.IsDir()) assert.True(t, info.IsDir())
} }
func TestSourceConfig_Struct_Good(t *testing.T) { func TestSourceConfig_Struct(t *testing.T) {
cfg := SourceConfig{ cfg := SourceConfig{
GitHubRepo: "owner/repo", GitHubRepo: "owner/repo",
RegistryImage: "ghcr.io/owner/image", RegistryImage: "ghcr.io/owner/image",

View file

@ -2,12 +2,12 @@ package sources
import ( import (
"context" "context"
"os"
"os/exec"
"strings"
core "dappco.re/go/core" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/proc"
) )
// GitHubSource downloads images from GitHub Releases. // GitHubSource downloads images from GitHub Releases.
@ -19,10 +19,6 @@ type GitHubSource struct {
var _ ImageSource = (*GitHubSource)(nil) var _ ImageSource = (*GitHubSource)(nil)
// NewGitHubSource creates a new GitHub source. // NewGitHubSource creates a new GitHub source.
//
// Usage:
//
// src := NewGitHubSource(cfg)
func NewGitHubSource(cfg SourceConfig) *GitHubSource { func NewGitHubSource(cfg SourceConfig) *GitHubSource {
return &GitHubSource{config: cfg} return &GitHubSource{config: cfg}
} }
@ -34,18 +30,18 @@ func (s *GitHubSource) Name() string {
// Available checks if gh CLI is installed and authenticated. // Available checks if gh CLI is installed and authenticated.
func (s *GitHubSource) Available() bool { func (s *GitHubSource) Available() bool {
_, err := proc.LookPath("gh") _, err := exec.LookPath("gh")
if err != nil { if err != nil {
return false return false
} }
// Check if authenticated // Check if authenticated
cmd := proc.NewCommand("gh", "auth", "status") cmd := exec.Command("gh", "auth", "status")
return cmd.Run() == nil return cmd.Run() == nil
} }
// LatestVersion returns the latest release tag. // LatestVersion returns the latest release tag.
func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) { func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) {
cmd := proc.NewCommandContext(ctx, "gh", "release", "view", cmd := exec.CommandContext(ctx, "gh", "release", "view",
"-R", s.config.GitHubRepo, "-R", s.config.GitHubRepo,
"--json", "tagName", "--json", "tagName",
"-q", ".tagName", "-q", ".tagName",
@ -54,20 +50,20 @@ func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) {
if err != nil { if err != nil {
return "", coreerr.E("github.LatestVersion", "failed", err) return "", coreerr.E("github.LatestVersion", "failed", err)
} }
return core.Trim(string(out)), nil return strings.TrimSpace(string(out)), nil
} }
// Download downloads the image from the latest release. // Download downloads the image from the latest release.
func (s *GitHubSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error { func (s *GitHubSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error {
// Get release assets to find our image // Get release assets to find our image
cmd := proc.NewCommandContext(ctx, "gh", "release", "download", cmd := exec.CommandContext(ctx, "gh", "release", "download",
"-R", s.config.GitHubRepo, "-R", s.config.GitHubRepo,
"-p", s.config.ImageName, "-p", s.config.ImageName,
"-D", dest, "-D", dest,
"--clobber", "--clobber",
) )
cmd.Stdout = proc.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = proc.Stderr cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return coreerr.E("github.Download", "failed", err) return coreerr.E("github.Download", "failed", err)

View file

@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestGitHubSource_Available_Good(t *testing.T) { func TestGitHubSource_Good_Available(t *testing.T) {
src := NewGitHubSource(SourceConfig{ src := NewGitHubSource(SourceConfig{
GitHubRepo: "host-uk/core-images", GitHubRepo: "host-uk/core-images",
ImageName: "core-devops-darwin-arm64.qcow2", ImageName: "core-devops-darwin-arm64.qcow2",
@ -20,12 +20,12 @@ func TestGitHubSource_Available_Good(t *testing.T) {
_ = src.Available() _ = src.Available()
} }
func TestGitHubSource_Name_Good(t *testing.T) { func TestGitHubSource_Name(t *testing.T) {
src := NewGitHubSource(SourceConfig{}) src := NewGitHubSource(SourceConfig{})
assert.Equal(t, "github", src.Name()) assert.Equal(t, "github", src.Name())
} }
func TestGitHubSource_Config_Good(t *testing.T) { func TestGitHubSource_Config(t *testing.T) {
cfg := SourceConfig{ cfg := SourceConfig{
GitHubRepo: "owner/repo", GitHubRepo: "owner/repo",
ImageName: "test-image.qcow2", ImageName: "test-image.qcow2",
@ -37,7 +37,7 @@ func TestGitHubSource_Config_Good(t *testing.T) {
assert.Equal(t, "test-image.qcow2", src.config.ImageName) assert.Equal(t, "test-image.qcow2", src.config.ImageName)
} }
func TestGitHubSource_Multiple_Good(t *testing.T) { func TestGitHubSource_Good_Multiple(t *testing.T) {
// Test creating multiple sources with different configs // Test creating multiple sources with different configs
src1 := NewGitHubSource(SourceConfig{GitHubRepo: "org1/repo1", ImageName: "img1.qcow2"}) src1 := NewGitHubSource(SourceConfig{GitHubRepo: "org1/repo1", ImageName: "img1.qcow2"})
src2 := NewGitHubSource(SourceConfig{GitHubRepo: "org2/repo2", ImageName: "img2.qcow2"}) src2 := NewGitHubSource(SourceConfig{GitHubRepo: "org2/repo2", ImageName: "img2.qcow2"})
@ -48,7 +48,7 @@ func TestGitHubSource_Multiple_Good(t *testing.T) {
assert.Equal(t, "github", src2.Name()) assert.Equal(t, "github", src2.Name())
} }
func TestGitHub_NewGitHubSource_Good(t *testing.T) { func TestNewGitHubSource_Good(t *testing.T) {
cfg := SourceConfig{ cfg := SourceConfig{
GitHubRepo: "host-uk/core-images", GitHubRepo: "host-uk/core-images",
RegistryImage: "ghcr.io/host-uk/core-devops", RegistryImage: "ghcr.io/host-uk/core-devops",
@ -62,7 +62,7 @@ func TestGitHub_NewGitHubSource_Good(t *testing.T) {
assert.Equal(t, cfg.GitHubRepo, src.config.GitHubRepo) assert.Equal(t, cfg.GitHubRepo, src.config.GitHubRepo)
} }
func TestGitHubSource_InterfaceCompliance_Good(t *testing.T) { func TestGitHubSource_InterfaceCompliance(t *testing.T) {
// Verify GitHubSource implements ImageSource // Verify GitHubSource implements ImageSource
var _ ImageSource = (*GitHubSource)(nil) var _ ImageSource = (*GitHubSource)(nil)
} }

View file

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

View file

@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestSourceConfig_Empty_Good(t *testing.T) { func TestSourceConfig_Empty(t *testing.T) {
cfg := SourceConfig{} cfg := SourceConfig{}
assert.Empty(t, cfg.GitHubRepo) assert.Empty(t, cfg.GitHubRepo)
assert.Empty(t, cfg.RegistryImage) assert.Empty(t, cfg.RegistryImage)
@ -14,7 +14,7 @@ func TestSourceConfig_Empty_Good(t *testing.T) {
assert.Empty(t, cfg.ImageName) assert.Empty(t, cfg.ImageName)
} }
func TestSourceConfig_Complete_Good(t *testing.T) { func TestSourceConfig_Complete(t *testing.T) {
cfg := SourceConfig{ cfg := SourceConfig{
GitHubRepo: "owner/repo", GitHubRepo: "owner/repo",
RegistryImage: "ghcr.io/owner/image:v1", RegistryImage: "ghcr.io/owner/image:v1",
@ -28,7 +28,7 @@ func TestSourceConfig_Complete_Good(t *testing.T) {
assert.Equal(t, "my-image-darwin-arm64.qcow2", cfg.ImageName) assert.Equal(t, "my-image-darwin-arm64.qcow2", cfg.ImageName)
} }
func TestImageSource_Interface_Good(t *testing.T) { func TestImageSource_Interface(t *testing.T) {
// Ensure both sources implement the interface // Ensure both sources implement the interface
var _ ImageSource = (*GitHubSource)(nil) var _ ImageSource = (*GitHubSource)(nil)
var _ ImageSource = (*CDNSource)(nil) var _ ImageSource = (*CDNSource)(nil)

View file

@ -1,13 +1,12 @@
package container package container
import ( import (
"io/fs" "encoding/json"
"os"
"path/filepath"
"sync" "sync"
core "dappco.re/go/core" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io"
"dappco.re/go/core/container/internal/coreutil"
) )
// State manages persistent container state. // State manages persistent container state.
@ -20,49 +19,33 @@ type State struct {
} }
// DefaultStateDir returns the default directory for state files (~/.core). // DefaultStateDir returns the default directory for state files (~/.core).
//
// Usage:
//
// dir, err := DefaultStateDir()
func DefaultStateDir() (string, error) { func DefaultStateDir() (string, error) {
home := coreutil.HomeDir() home, err := os.UserHomeDir()
if home == "" { if err != nil {
return "", core.E("DefaultStateDir", "home directory not available", nil) return "", err
} }
return coreutil.JoinPath(home, ".core"), nil return filepath.Join(home, ".core"), nil
} }
// DefaultStatePath returns the default path for the state file. // DefaultStatePath returns the default path for the state file.
//
// Usage:
//
// path, err := DefaultStatePath()
func DefaultStatePath() (string, error) { func DefaultStatePath() (string, error) {
dir, err := DefaultStateDir() dir, err := DefaultStateDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return coreutil.JoinPath(dir, "containers.json"), nil return filepath.Join(dir, "containers.json"), nil
} }
// DefaultLogsDir returns the default directory for container logs. // DefaultLogsDir returns the default directory for container logs.
//
// Usage:
//
// dir, err := DefaultLogsDir()
func DefaultLogsDir() (string, error) { func DefaultLogsDir() (string, error) {
dir, err := DefaultStateDir() dir, err := DefaultStateDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return coreutil.JoinPath(dir, "logs"), nil return filepath.Join(dir, "logs"), nil
} }
// NewState creates a new State instance. // NewState creates a new State instance.
//
// Usage:
//
// state := NewState("/tmp/containers.json")
func NewState(filePath string) *State { func NewState(filePath string) *State {
return &State{ return &State{
Containers: make(map[string]*Container), Containers: make(map[string]*Container),
@ -72,24 +55,19 @@ func NewState(filePath string) *State {
// LoadState loads the state from the given file path. // LoadState loads the state from the given file path.
// If the file doesn't exist, returns an empty state. // If the file doesn't exist, returns an empty state.
//
// Usage:
//
// state, err := LoadState("/tmp/containers.json")
func LoadState(filePath string) (*State, error) { func LoadState(filePath string) (*State, error) {
state := NewState(filePath) state := NewState(filePath)
dataStr, err := io.Local.Read(filePath) dataStr, err := io.Local.Read(filePath)
if err != nil { if err != nil {
if core.Is(err, fs.ErrNotExist) { if os.IsNotExist(err) {
return state, nil return state, nil
} }
return nil, err return nil, err
} }
result := core.JSONUnmarshalString(dataStr, state) if err := json.Unmarshal([]byte(dataStr), state); err != nil {
if !result.OK { return nil, err
return nil, result.Value.(error)
} }
return state, nil return state, nil
@ -101,17 +79,17 @@ func (s *State) SaveState() error {
defer s.mu.RUnlock() defer s.mu.RUnlock()
// Ensure the directory exists // Ensure the directory exists
dir := core.PathDir(s.filePath) dir := filepath.Dir(s.filePath)
if err := io.Local.EnsureDir(dir); err != nil { if err := io.Local.EnsureDir(dir); err != nil {
return err return err
} }
result := core.JSONMarshal(s) data, err := json.MarshalIndent(s, "", " ")
if !result.OK { if err != nil {
return result.Value.(error) return err
} }
return io.Local.Write(s.filePath, string(result.Value.([]byte))) return io.Local.Write(s.filePath, string(data))
} }
// Add adds a container to the state and persists it. // Add adds a container to the state and persists it.
@ -176,23 +154,15 @@ func (s *State) FilePath() string {
} }
// LogPath returns the log file path for a given container ID. // LogPath returns the log file path for a given container ID.
//
// Usage:
//
// path, err := LogPath(containerID)
func LogPath(id string) (string, error) { func LogPath(id string) (string, error) {
logsDir, err := DefaultLogsDir() logsDir, err := DefaultLogsDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return coreutil.JoinPath(logsDir, core.Concat(id, ".log")), nil return filepath.Join(logsDir, id+".log"), nil
} }
// EnsureLogsDir ensures the logs directory exists. // EnsureLogsDir ensures the logs directory exists.
//
// Usage:
//
// err := EnsureLogsDir()
func EnsureLogsDir() error { func EnsureLogsDir() error {
logsDir, err := DefaultLogsDir() logsDir, err := DefaultLogsDir()
if err != nil { if err != nil {

View file

@ -1,18 +1,16 @@
package container package container
import ( import (
"os"
"path/filepath"
"testing" "testing"
"time" "time"
core "dappco.re/go/core"
"dappco.re/go/core/io"
"dappco.re/go/core/container/internal/coreutil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestState_NewState_Good(t *testing.T) { func TestNewState_Good(t *testing.T) {
state := NewState("/tmp/test-state.json") state := NewState("/tmp/test-state.json")
assert.NotNil(t, state) assert.NotNil(t, state)
@ -20,10 +18,10 @@ func TestState_NewState_Good(t *testing.T) {
assert.Equal(t, "/tmp/test-state.json", state.FilePath()) assert.Equal(t, "/tmp/test-state.json", state.FilePath())
} }
func TestLoadState_NewFile_Good(t *testing.T) { func TestLoadState_Good_NewFile(t *testing.T) {
// Test loading from non-existent file // Test loading from non-existent file
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
state, err := LoadState(statePath) state, err := LoadState(statePath)
@ -32,9 +30,9 @@ func TestLoadState_NewFile_Good(t *testing.T) {
assert.Empty(t, state.Containers) assert.Empty(t, state.Containers)
} }
func TestLoadState_ExistingFile_Good(t *testing.T) { func TestLoadState_Good_ExistingFile(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
// Create a state file with data // Create a state file with data
content := `{ content := `{
@ -49,7 +47,7 @@ func TestLoadState_ExistingFile_Good(t *testing.T) {
} }
} }
}` }`
err := io.Local.Write(statePath, content) err := os.WriteFile(statePath, []byte(content), 0644)
require.NoError(t, err) require.NoError(t, err)
state, err := LoadState(statePath) state, err := LoadState(statePath)
@ -63,12 +61,12 @@ func TestLoadState_ExistingFile_Good(t *testing.T) {
assert.Equal(t, StatusRunning, c.Status) assert.Equal(t, StatusRunning, c.Status)
} }
func TestLoadState_InvalidJSON_Bad(t *testing.T) { func TestLoadState_Bad_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
// Create invalid JSON // Create invalid JSON
err := io.Local.Write(statePath, "invalid json{") err := os.WriteFile(statePath, []byte("invalid json{"), 0644)
require.NoError(t, err) require.NoError(t, err)
_, err = LoadState(statePath) _, err = LoadState(statePath)
@ -77,7 +75,7 @@ func TestLoadState_InvalidJSON_Bad(t *testing.T) {
func TestState_Add_Good(t *testing.T) { func TestState_Add_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
state := NewState(statePath) state := NewState(statePath)
container := &Container{ container := &Container{
@ -98,12 +96,13 @@ func TestState_Add_Good(t *testing.T) {
assert.Equal(t, container.Name, c.Name) assert.Equal(t, container.Name, c.Name)
// Verify file was created // Verify file was created
assert.True(t, io.Local.IsFile(statePath)) _, err = os.Stat(statePath)
assert.NoError(t, err)
} }
func TestState_Update_Good(t *testing.T) { func TestState_Update_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
state := NewState(statePath) state := NewState(statePath)
container := &Container{ container := &Container{
@ -125,7 +124,7 @@ func TestState_Update_Good(t *testing.T) {
func TestState_Remove_Good(t *testing.T) { func TestState_Remove_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
state := NewState(statePath) state := NewState(statePath)
container := &Container{ container := &Container{
@ -140,7 +139,7 @@ func TestState_Remove_Good(t *testing.T) {
assert.False(t, ok) assert.False(t, ok)
} }
func TestState_Get_NotFound_Bad(t *testing.T) { func TestState_Get_Bad_NotFound(t *testing.T) {
state := NewState("/tmp/test-state.json") state := NewState("/tmp/test-state.json")
_, ok := state.Get("nonexistent") _, ok := state.Get("nonexistent")
@ -149,7 +148,7 @@ func TestState_Get_NotFound_Bad(t *testing.T) {
func TestState_All_Good(t *testing.T) { func TestState_All_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
statePath := coreutil.JoinPath(tmpDir, "containers.json") statePath := filepath.Join(tmpDir, "containers.json")
state := NewState(statePath) state := NewState(statePath)
_ = state.Add(&Container{ID: "aaa11111"}) _ = state.Add(&Container{ID: "aaa11111"})
@ -160,9 +159,9 @@ func TestState_All_Good(t *testing.T) {
assert.Len(t, all, 3) assert.Len(t, all, 3)
} }
func TestState_SaveState_CreatesDirectory_Good(t *testing.T) { func TestState_SaveState_Good_CreatesDirectory(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
nestedPath := coreutil.JoinPath(tmpDir, "nested", "dir", "containers.json") nestedPath := filepath.Join(tmpDir, "nested", "dir", "containers.json")
state := NewState(nestedPath) state := NewState(nestedPath)
_ = state.Add(&Container{ID: "abc12345"}) _ = state.Add(&Container{ID: "abc12345"})
@ -171,43 +170,45 @@ func TestState_SaveState_CreatesDirectory_Good(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Verify directory was created // Verify directory was created
assert.True(t, io.Local.IsDir(core.PathDir(nestedPath))) _, err = os.Stat(filepath.Dir(nestedPath))
assert.NoError(t, err)
} }
func TestState_DefaultStateDir_Good(t *testing.T) { func TestDefaultStateDir_Good(t *testing.T) {
dir, err := DefaultStateDir() dir, err := DefaultStateDir()
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, dir, ".core") assert.Contains(t, dir, ".core")
} }
func TestState_DefaultStatePath_Good(t *testing.T) { func TestDefaultStatePath_Good(t *testing.T) {
path, err := DefaultStatePath() path, err := DefaultStatePath()
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, path, "containers.json") assert.Contains(t, path, "containers.json")
} }
func TestState_DefaultLogsDir_Good(t *testing.T) { func TestDefaultLogsDir_Good(t *testing.T) {
dir, err := DefaultLogsDir() dir, err := DefaultLogsDir()
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, dir, "logs") assert.Contains(t, dir, "logs")
} }
func TestState_LogPath_Good(t *testing.T) { func TestLogPath_Good(t *testing.T) {
path, err := LogPath("abc12345") path, err := LogPath("abc12345")
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, path, "abc12345.log") assert.Contains(t, path, "abc12345.log")
} }
func TestState_EnsureLogsDir_Good(t *testing.T) { func TestEnsureLogsDir_Good(t *testing.T) {
// This test creates real directories - skip in CI if needed // This test creates real directories - skip in CI if needed
err := EnsureLogsDir() err := EnsureLogsDir()
assert.NoError(t, err) assert.NoError(t, err)
logsDir, _ := DefaultLogsDir() logsDir, _ := DefaultLogsDir()
assert.True(t, io.Local.IsDir(logsDir)) _, err = os.Stat(logsDir)
assert.NoError(t, err)
} }
func TestState_GenerateID_Good(t *testing.T) { func TestGenerateID_Good(t *testing.T) {
id1, err := GenerateID() id1, err := GenerateID()
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, id1, 8) assert.Len(t, id1, 8)

View file

@ -4,14 +4,14 @@ import (
"embed" "embed"
"iter" "iter"
"maps" "maps"
"os"
"path/filepath"
"regexp" "regexp"
"slices" "slices"
"strings"
core "dappco.re/go/core" "forge.lthn.ai/core/go-io"
"dappco.re/go/core/io" coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/container/internal/coreutil"
) )
//go:embed templates/*.yml //go:embed templates/*.yml
@ -44,19 +44,11 @@ var builtinTemplates = []Template{
// ListTemplates returns all available LinuxKit templates. // ListTemplates returns all available LinuxKit templates.
// It combines embedded templates with any templates found in the user's // It combines embedded templates with any templates found in the user's
// .core/linuxkit directory. // .core/linuxkit directory.
//
// Usage:
//
// templates := ListTemplates()
func ListTemplates() []Template { func ListTemplates() []Template {
return slices.Collect(ListTemplatesIter()) return slices.Collect(ListTemplatesIter())
} }
// ListTemplatesIter returns an iterator for all available LinuxKit templates. // ListTemplatesIter returns an iterator for all available LinuxKit templates.
//
// Usage:
//
// for template := range ListTemplatesIter() { _ = template }
func ListTemplatesIter() iter.Seq[Template] { func ListTemplatesIter() iter.Seq[Template] {
return func(yield func(Template) bool) { return func(yield func(Template) bool) {
// Yield builtin templates // Yield builtin templates
@ -80,10 +72,6 @@ func ListTemplatesIter() iter.Seq[Template] {
// GetTemplate returns the content of a template by name. // GetTemplate returns the content of a template by name.
// It first checks embedded templates, then user templates. // It first checks embedded templates, then user templates.
//
// Usage:
//
// content, err := GetTemplate("core-dev")
func GetTemplate(name string) (string, error) { func GetTemplate(name string) (string, error) {
// Check embedded templates first // Check embedded templates first
for _, t := range builtinTemplates { for _, t := range builtinTemplates {
@ -99,7 +87,7 @@ func GetTemplate(name string) (string, error) {
// Check user templates // Check user templates
userTemplatesDir := getUserTemplatesDir() userTemplatesDir := getUserTemplatesDir()
if userTemplatesDir != "" { if userTemplatesDir != "" {
templatePath := coreutil.JoinPath(userTemplatesDir, core.Concat(name, ".yml")) templatePath := filepath.Join(userTemplatesDir, name+".yml")
if io.Local.IsFile(templatePath) { if io.Local.IsFile(templatePath) {
content, err := io.Local.Read(templatePath) content, err := io.Local.Read(templatePath)
if err != nil { if err != nil {
@ -116,10 +104,6 @@ func GetTemplate(name string) (string, error) {
// It supports two syntaxes: // It supports two syntaxes:
// - ${VAR} - required variable, returns error if not provided // - ${VAR} - required variable, returns error if not provided
// - ${VAR:-default} - variable with default value // - ${VAR:-default} - variable with default value
//
// Usage:
//
// content, err := ApplyTemplate("core-dev", vars)
func ApplyTemplate(name string, vars map[string]string) (string, error) { func ApplyTemplate(name string, vars map[string]string) (string, error) {
content, err := GetTemplate(name) content, err := GetTemplate(name)
if err != nil { if err != nil {
@ -133,10 +117,6 @@ func ApplyTemplate(name string, vars map[string]string) (string, error) {
// It supports two syntaxes: // It supports two syntaxes:
// - ${VAR} - required variable, returns error if not provided // - ${VAR} - required variable, returns error if not provided
// - ${VAR:-default} - variable with default value // - ${VAR:-default} - variable with default value
//
// Usage:
//
// content, err := ApplyVariables(raw, vars)
func ApplyVariables(content string, vars map[string]string) (string, error) { func ApplyVariables(content string, vars map[string]string) (string, error) {
// Pattern for ${VAR:-default} syntax // Pattern for ${VAR:-default} syntax
defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`) defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`)
@ -178,7 +158,7 @@ func ApplyVariables(content string, vars map[string]string) (string, error) {
}) })
if len(missingVars) > 0 { if len(missingVars) > 0 {
return "", coreerr.E("ApplyVariables", core.Concat("missing required variables: ", core.Join(", ", missingVars...)), nil) return "", coreerr.E("ApplyVariables", "missing required variables: "+strings.Join(missingVars, ", "), nil)
} }
return result, nil return result, nil
@ -186,10 +166,6 @@ func ApplyVariables(content string, vars map[string]string) (string, error) {
// ExtractVariables extracts all variable names from a template. // ExtractVariables extracts all variable names from a template.
// Returns two slices: required variables and optional variables (with defaults). // Returns two slices: required variables and optional variables (with defaults).
//
// Usage:
//
// required, optional := ExtractVariables(content)
func ExtractVariables(content string) (required []string, optional map[string]string) { func ExtractVariables(content string) (required []string, optional map[string]string) {
optional = make(map[string]string) optional = make(map[string]string)
requiredSet := make(map[string]bool) requiredSet := make(map[string]bool)
@ -230,18 +206,21 @@ func ExtractVariables(content string) (required []string, optional map[string]st
// Returns empty string if the directory doesn't exist. // Returns empty string if the directory doesn't exist.
func getUserTemplatesDir() string { func getUserTemplatesDir() string {
// Try workspace-relative .core/linuxkit first // Try workspace-relative .core/linuxkit first
wsDir := coreutil.JoinPath(coreutil.CurrentDir(), ".core", "linuxkit") cwd, err := os.Getwd()
if io.Local.IsDir(wsDir) { if err == nil {
return wsDir wsDir := filepath.Join(cwd, ".core", "linuxkit")
if io.Local.IsDir(wsDir) {
return wsDir
}
} }
// Try home directory // Try home directory
home := coreutil.HomeDir() home, err := os.UserHomeDir()
if home == "" { if err != nil {
return "" return ""
} }
homeDir := coreutil.JoinPath(home, ".core", "linuxkit") homeDir := filepath.Join(home, ".core", "linuxkit")
if io.Local.IsDir(homeDir) { if io.Local.IsDir(homeDir) {
return homeDir return homeDir
} }
@ -264,12 +243,12 @@ func scanUserTemplates(dir string) []Template {
} }
name := entry.Name() name := entry.Name()
if !core.HasSuffix(name, ".yml") && !core.HasSuffix(name, ".yaml") { if !strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml") {
continue continue
} }
// Extract template name from filename // Extract template name from filename
templateName := core.TrimSuffix(core.TrimSuffix(name, ".yml"), ".yaml") templateName := strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml")
// Skip if this is a builtin template name (embedded takes precedence) // Skip if this is a builtin template name (embedded takes precedence)
isBuiltin := false isBuiltin := false
@ -284,7 +263,7 @@ func scanUserTemplates(dir string) []Template {
} }
// Read file to extract description from comments // Read file to extract description from comments
description := extractTemplateDescription(coreutil.JoinPath(dir, name)) description := extractTemplateDescription(filepath.Join(dir, name))
if description == "" { if description == "" {
description = "User-defined template" description = "User-defined template"
} }
@ -292,7 +271,7 @@ func scanUserTemplates(dir string) []Template {
templates = append(templates, Template{ templates = append(templates, Template{
Name: templateName, Name: templateName,
Description: description, Description: description,
Path: coreutil.JoinPath(dir, name), Path: filepath.Join(dir, name),
}) })
} }
@ -307,14 +286,14 @@ func extractTemplateDescription(path string) string {
return "" return ""
} }
lines := core.Split(content, "\n") lines := strings.Split(content, "\n")
var descLines []string var descLines []string
for _, line := range lines { for _, line := range lines {
trimmed := core.Trim(line) trimmed := strings.TrimSpace(line)
if core.HasPrefix(trimmed, "#") { if strings.HasPrefix(trimmed, "#") {
// Remove the # and trim // Remove the # and trim
comment := core.Trim(core.TrimPrefix(trimmed, "#")) comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if comment != "" { if comment != "" {
descLines = append(descLines, comment) descLines = append(descLines, comment)
// Only take the first meaningful comment line as description // Only take the first meaningful comment line as description

View file

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