diff --git a/CLAUDE.md b/CLAUDE.md index c8d8812..c67a493 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Three packages with a clear dependency direction: `devenv` -> `container` (root) ## Coding Standards - UK English (colour, organisation, honour) -- Tests use testify; naming convention: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (edge cases) -- Error wrapping: `fmt.Errorf("context: %w", err)` +- Tests use testify; naming convention: `TestSubject_Function_{Good,Bad,Ugly}` +- Error wrapping: `core.E("Op", "message", err)` - Context propagation: all blocking operations take `context.Context` as first parameter - Licence: EUPL-1.2 diff --git a/CONSUMERS.md b/CONSUMERS.md index acb1c04..6031a48 100644 --- a/CONSUMERS.md +++ b/CONSUMERS.md @@ -1,6 +1,6 @@ # Consumers of go-container -These modules import `forge.lthn.ai/core/go-container`: +These modules import `dappco.re/go/core/container`: - core - go-devops diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..5816dc2 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,436 @@ +# Upgrade Report: dappco.re/go/core v0.8.0-alpha.1 + +## Scope + +- Repository: `core/go-container` +- Branch: `agent/create-an-upgrade-plan-for-this-package` +- Requested target: `dappco.re/go/core v0.8.0-alpha.1` +- Consumers called out for break-risk review: `core`, `go-devops` + +## Baseline Verification + +- `go build ./...`: passed +- `go vet ./...`: passed +- `go test ./... -count=1 -timeout 120s`: passed +- `go test -cover ./...`: passed (`container` 81.7%, `cmd/vm` 0.0%, `devenv` 53.3%, `sources` 72.7%) +- `go mod tidy`: not run because this task is report-only and should not introduce dependency churn + +## 1. go.mod Upgrade Plan + +- Current core version: `dappco.re/go/core v0.5.0` at `go.mod:16` +- Required bump: `dappco.re/go/core v0.5.0` -> `dappco.re/go/core v0.8.0-alpha.1` at `go.mod:16` +- Direct `dappco.re/go/core/*` dependencies that should be compatibility-checked in the same upgrade pass: + - `go.mod:6` `dappco.re/go/core/i18n v0.2.0` + - `go.mod:7` `dappco.re/go/core/io v0.2.0` + - `go.mod:8` `dappco.re/go/core/log v0.1.0` +- Legacy `forge.lthn.ai` modules still present in `go.mod`; these should be reviewed during the core bump because they may pin older transitive core APIs: + - `go.mod:9` `forge.lthn.ai/core/cli v0.3.7` + - `go.mod:10` `forge.lthn.ai/core/config v0.1.8` + - `go.mod:17` `forge.lthn.ai/core/go v0.3.3` + - `go.mod:18` `forge.lthn.ai/core/go-i18n v0.1.7` + - `go.mod:19` `forge.lthn.ai/core/go-inference v0.1.6` + - `go.mod:20` `forge.lthn.ai/core/go-io v0.1.7` + - `go.mod:21` `forge.lthn.ai/core/go-log v0.0.4` + +## 2. Banned Stdlib Imports + +Each group lists every import site and the required Core replacement. + +### `os` + +- Replacement: Replace with core.Env/core.Fs +- `cmd/vm/cmd_container.go:7` +- `cmd/vm/cmd_templates.go:6` +- `devenv/claude.go:6` +- `devenv/config.go:4` +- `devenv/config_test.go:4` +- `devenv/devops.go:7` +- `devenv/devops_test.go:5` +- `devenv/images.go:7` +- `devenv/images_test.go:5` +- `devenv/serve.go:6` +- `devenv/serve_test.go:4` +- `devenv/shell.go:6` +- `devenv/ssh_utils.go:6` +- `devenv/test_test.go:4` +- `hypervisor.go:6` +- `linuxkit.go:8` +- `linuxkit_test.go:5` +- `sources/cdn.go:8` +- `sources/cdn_test.go:8` +- `sources/github.go:5` +- `state.go:5` +- `state_test.go:4` +- `templates.go:7` +- `templates_test.go:4` + +### `os/exec` + +- Replacement: No direct replacement was provided in the task; requires a manual audit for the v0.8.0-alpha.1 command-exec path +- `cmd/vm/cmd_templates.go:7` +- `devenv/claude.go:7` +- `devenv/devops_test.go:6` +- `devenv/serve.go:7` +- `devenv/shell.go:7` +- `devenv/ssh_utils.go:7` +- `hypervisor.go:7` +- `linuxkit.go:9` +- `linuxkit_test.go:6` +- `sources/github.go:6` + +### `encoding/json` + +- Replacement: Replace with core.JSONMarshalString/JSONUnmarshalString +- `devenv/images.go:5` +- `devenv/test.go:5` +- `state.go:4` + +### `fmt` + +- Replacement: Replace with core.Sprintf/core.Concat/core.E +- `cmd/vm/cmd_container.go:5` +- `cmd/vm/cmd_templates.go:5` +- `devenv/claude.go:5` +- `devenv/devops.go:6` +- `devenv/images.go:6` +- `devenv/serve.go:5` +- `devenv/shell.go:5` +- `devenv/ssh_utils.go:5` +- `hypervisor.go:5` +- `linuxkit.go:6` +- `sources/cdn.go:5` +- `sources/cdn_test.go:5` + +### `errors` + +- Replacement: Replace with core.E/core.Is +- No occurrences found + +### `strings` + +- Replacement: Replace with core.Contains/core.HasPrefix/core.Split/core.Trim/core.Replace +- `cmd/vm/cmd_container.go:8` +- `cmd/vm/cmd_templates.go:9` +- `devenv/claude.go:9` +- `devenv/ssh_utils.go:9` +- `devenv/test.go:7` +- `hypervisor.go:10` +- `sources/github.go:7` +- `templates.go:11` +- `templates_test.go:6` + +### `path/filepath` + +- Replacement: Replace with core.JoinPath/core.PathBase/core.PathDir +- `cmd/vm/cmd_templates.go:8` +- `devenv/claude.go:8` +- `devenv/config.go:5` +- `devenv/config_test.go:5` +- `devenv/devops.go:8` +- `devenv/devops_test.go:7` +- `devenv/images.go:8` +- `devenv/images_test.go:6` +- `devenv/serve.go:8` +- `devenv/serve_test.go:5` +- `devenv/ssh_utils.go:8` +- `devenv/test.go:6` +- `devenv/test_test.go:5` +- `hypervisor.go:8` +- `linuxkit_test.go:7` +- `sources/cdn.go:9` +- `sources/cdn_test.go:9` +- `state.go:6` +- `state_test.go:5` +- `templates.go:8` +- `templates_test.go:5` + +## 3. Tests Not Matching `TestFile_Function_{Good,Bad,Ugly}` + +- Total mismatches found: 236 + +- `devenv/claude_test.go:9` `TestClaudeOptions_Default` +- `devenv/claude_test.go:16` `TestClaudeOptions_Custom` +- `devenv/claude_test.go:27` `TestFormatAuthList_Good_NoAuth` +- `devenv/claude_test.go:33` `TestFormatAuthList_Good_Default` +- `devenv/claude_test.go:39` `TestFormatAuthList_Good_CustomAuth` +- `devenv/claude_test.go:47` `TestFormatAuthList_Good_MultipleAuth` +- `devenv/claude_test.go:55` `TestFormatAuthList_Good_EmptyAuth` +- `devenv/config_test.go:13` `TestDefaultConfig` +- `devenv/config_test.go:20` `TestConfigPath` +- `devenv/config_test.go:26` `TestLoadConfig_Good` +- `devenv/config_test.go:65` `TestLoadConfig_Bad` +- `devenv/config_test.go:82` `TestConfig_Struct` +- `devenv/config_test.go:105` `TestDefaultConfig_Complete` +- `devenv/config_test.go:114` `TestLoadConfig_Good_PartialConfig` +- `devenv/config_test.go:139` `TestLoadConfig_Good_AllSourceTypes` +- `devenv/config_test.go:208` `TestImagesConfig_Struct` +- `devenv/config_test.go:217` `TestGitHubConfig_Struct` +- `devenv/config_test.go:222` `TestRegistryConfig_Struct` +- `devenv/config_test.go:227` `TestCDNConfig_Struct` +- `devenv/config_test.go:232` `TestLoadConfig_Bad_UnreadableFile` +- `devenv/devops_test.go:18` `TestImageName` +- `devenv/devops_test.go:26` `TestImagesDir` +- `devenv/devops_test.go:48` `TestImagePath` +- `devenv/devops_test.go:58` `TestDefaultBootOptions` +- `devenv/devops_test.go:66` `TestIsInstalled_Bad` +- `devenv/devops_test.go:78` `TestIsInstalled_Good` +- `devenv/devops_test.go:142` `TestDevOps_Status_Good_NotInstalled` +- `devenv/devops_test.go:168` `TestDevOps_Status_Good_NoContainer` +- `devenv/devops_test.go:232` `TestDevOps_IsRunning_Bad_NotRunning` +- `devenv/devops_test.go:255` `TestDevOps_IsRunning_Bad_ContainerStopped` +- `devenv/devops_test.go:323` `TestDevOps_findContainer_Bad_NotFound` +- `devenv/devops_test.go:346` `TestDevOps_Stop_Bad_NotFound` +- `devenv/devops_test.go:369` `TestBootOptions_Custom` +- `devenv/devops_test.go:382` `TestDevStatus_Struct` +- `devenv/devops_test.go:403` `TestDevOps_Boot_Bad_NotInstalled` +- `devenv/devops_test.go:426` `TestDevOps_Boot_Bad_AlreadyRunning` +- `devenv/devops_test.go:465` `TestDevOps_Status_Good_WithImageVersion` +- `devenv/devops_test.go:501` `TestDevOps_findContainer_Good_MultipleContainers` +- `devenv/devops_test.go:546` `TestDevOps_Status_Good_ContainerWithUptime` +- `devenv/devops_test.go:583` `TestDevOps_IsRunning_Bad_DifferentContainerName` +- `devenv/devops_test.go:618` `TestDevOps_Boot_Good_FreshFlag` +- `devenv/devops_test.go:668` `TestDevOps_Stop_Bad_ContainerNotRunning` +- `devenv/devops_test.go:703` `TestDevOps_Boot_Good_FreshWithNoExisting` +- `devenv/devops_test.go:741` `TestImageName_Format` +- `devenv/devops_test.go:750` `TestDevOps_Install_Delegates` +- `devenv/devops_test.go:768` `TestDevOps_CheckUpdate_Delegates` +- `devenv/devops_test.go:786` `TestDevOps_Boot_Good_Success` +- `devenv/devops_test.go:818` `TestDevOps_Config` +- `devenv/images_test.go:16` `TestImageManager_Good_IsInstalled` +- `devenv/images_test.go:36` `TestNewImageManager_Good` +- `devenv/images_test.go:66` `TestManifest_Save` +- `devenv/images_test.go:94` `TestLoadManifest_Bad` +- `devenv/images_test.go:106` `TestCheckUpdate_Bad` +- `devenv/images_test.go:121` `TestNewImageManager_Good_AutoSource` +- `devenv/images_test.go:134` `TestNewImageManager_Good_UnknownSourceFallsToAuto` +- `devenv/images_test.go:147` `TestLoadManifest_Good_Empty` +- `devenv/images_test.go:159` `TestLoadManifest_Good_ExistingData` +- `devenv/images_test.go:174` `TestImageInfo_Struct` +- `devenv/images_test.go:187` `TestManifest_Save_Good_CreatesDirs` +- `devenv/images_test.go:207` `TestManifest_Save_Good_Overwrite` +- `devenv/images_test.go:239` `TestImageManager_Install_Bad_NoSourceAvailable` +- `devenv/images_test.go:256` `TestNewImageManager_Good_CreatesDir` +- `devenv/images_test.go:295` `TestImageManager_Install_Good_WithMockSource` +- `devenv/images_test.go:323` `TestImageManager_Install_Bad_DownloadError` +- `devenv/images_test.go:345` `TestImageManager_Install_Bad_VersionError` +- `devenv/images_test.go:367` `TestImageManager_Install_Good_SkipsUnavailableSource` +- `devenv/images_test.go:396` `TestImageManager_CheckUpdate_Good_WithMockSource` +- `devenv/images_test.go:426` `TestImageManager_CheckUpdate_Good_NoUpdate` +- `devenv/images_test.go:456` `TestImageManager_CheckUpdate_Bad_NoSource` +- `devenv/images_test.go:483` `TestImageManager_CheckUpdate_Bad_VersionError` +- `devenv/images_test.go:511` `TestImageManager_Install_Bad_EmptySources` +- `devenv/images_test.go:527` `TestImageManager_Install_Bad_AllUnavailable` +- `devenv/images_test.go:546` `TestImageManager_CheckUpdate_Good_FirstSourceUnavailable` +- `devenv/images_test.go:573` `TestManifest_Struct` +- `devenv/serve_test.go:12` `TestDetectServeCommand_Good_Laravel` +- `devenv/serve_test.go:21` `TestDetectServeCommand_Good_NodeDev` +- `devenv/serve_test.go:31` `TestDetectServeCommand_Good_NodeStart` +- `devenv/serve_test.go:41` `TestDetectServeCommand_Good_PHP` +- `devenv/serve_test.go:50` `TestDetectServeCommand_Good_GoMain` +- `devenv/serve_test.go:61` `TestDetectServeCommand_Good_GoWithoutMain` +- `devenv/serve_test.go:71` `TestDetectServeCommand_Good_Django` +- `devenv/serve_test.go:80` `TestDetectServeCommand_Good_Fallback` +- `devenv/serve_test.go:87` `TestDetectServeCommand_Good_Priority` +- `devenv/serve_test.go:99` `TestServeOptions_Default` +- `devenv/serve_test.go:105` `TestServeOptions_Custom` +- `devenv/serve_test.go:114` `TestHasFile_Good` +- `devenv/serve_test.go:123` `TestHasFile_Bad` +- `devenv/serve_test.go:129` `TestHasFile_Bad_Directory` +- `devenv/shell_test.go:9` `TestShellOptions_Default` +- `devenv/shell_test.go:15` `TestShellOptions_Console` +- `devenv/shell_test.go:23` `TestShellOptions_Command` +- `devenv/shell_test.go:31` `TestShellOptions_ConsoleWithCommand` +- `devenv/shell_test.go:40` `TestShellOptions_EmptyCommand` +- `devenv/test_test.go:11` `TestDetectTestCommand_Good_ComposerJSON` +- `devenv/test_test.go:21` `TestDetectTestCommand_Good_PackageJSON` +- `devenv/test_test.go:31` `TestDetectTestCommand_Good_GoMod` +- `devenv/test_test.go:41` `TestDetectTestCommand_Good_CoreTestYaml` +- `devenv/test_test.go:53` `TestDetectTestCommand_Good_Pytest` +- `devenv/test_test.go:63` `TestDetectTestCommand_Good_Taskfile` +- `devenv/test_test.go:73` `TestDetectTestCommand_Bad_NoFiles` +- `devenv/test_test.go:82` `TestDetectTestCommand_Good_Priority` +- `devenv/test_test.go:96` `TestLoadTestConfig_Good` +- `devenv/test_test.go:135` `TestLoadTestConfig_Bad_NotFound` +- `devenv/test_test.go:144` `TestHasPackageScript_Good` +- `devenv/test_test.go:156` `TestHasPackageScript_Bad_MissingScript` +- `devenv/test_test.go:165` `TestHasComposerScript_Good` +- `devenv/test_test.go:174` `TestHasComposerScript_Bad_MissingScript` +- `devenv/test_test.go:183` `TestTestConfig_Struct` +- `devenv/test_test.go:204` `TestTestCommand_Struct` +- `devenv/test_test.go:217` `TestTestOptions_Struct` +- `devenv/test_test.go:230` `TestDetectTestCommand_Good_TaskfileYml` +- `devenv/test_test.go:240` `TestDetectTestCommand_Good_Pyproject` +- `devenv/test_test.go:250` `TestHasPackageScript_Bad_NoFile` +- `devenv/test_test.go:258` `TestHasPackageScript_Bad_InvalidJSON` +- `devenv/test_test.go:267` `TestHasPackageScript_Bad_NoScripts` +- `devenv/test_test.go:276` `TestHasComposerScript_Bad_NoFile` +- `devenv/test_test.go:284` `TestHasComposerScript_Bad_InvalidJSON` +- `devenv/test_test.go:293` `TestHasComposerScript_Bad_NoScripts` +- `devenv/test_test.go:302` `TestLoadTestConfig_Bad_InvalidYAML` +- `devenv/test_test.go:314` `TestLoadTestConfig_Good_MinimalConfig` +- `devenv/test_test.go:332` `TestDetectTestCommand_Good_ComposerWithoutScript` +- `devenv/test_test.go:344` `TestDetectTestCommand_Good_PackageJSONWithoutScript` +- `hypervisor_test.go:23` `TestQemuHypervisor_Available_Bad_InvalidBinary` +- `hypervisor_test.go:47` `TestHyperkitHypervisor_Available_Bad_NotDarwin` +- `hypervisor_test.go:59` `TestHyperkitHypervisor_Available_Bad_InvalidBinary` +- `hypervisor_test.go:69` `TestIsKVMAvailable_Good` +- `hypervisor_test.go:83` `TestDetectHypervisor_Good` +- `hypervisor_test.go:98` `TestGetHypervisor_Good_Qemu` +- `hypervisor_test.go:110` `TestGetHypervisor_Good_QemuUppercase` +- `hypervisor_test.go:122` `TestGetHypervisor_Good_Hyperkit` +- `hypervisor_test.go:140` `TestGetHypervisor_Bad_Unknown` +- `hypervisor_test.go:147` `TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes` +- `hypervisor_test.go:175` `TestQemuHypervisor_BuildCommand_Good_QCow2Format` +- `hypervisor_test.go:195` `TestQemuHypervisor_BuildCommand_Good_VMDKFormat` +- `hypervisor_test.go:215` `TestQemuHypervisor_BuildCommand_Good_RawFormat` +- `hypervisor_test.go:235` `TestHyperkitHypervisor_BuildCommand_Good_WithPorts` +- `hypervisor_test.go:258` `TestHyperkitHypervisor_BuildCommand_Good_QCow2Format` +- `hypervisor_test.go:269` `TestHyperkitHypervisor_BuildCommand_Good_RawFormat` +- `hypervisor_test.go:280` `TestHyperkitHypervisor_BuildCommand_Good_NoPorts` +- `hypervisor_test.go:296` `TestQemuHypervisor_BuildCommand_Good_NoSSHPort` +- `hypervisor_test.go:312` `TestQemuHypervisor_BuildCommand_Bad_UnknownFormat` +- `hypervisor_test.go:323` `TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat` +- `hypervisor_test.go:339` `TestHyperkitHypervisor_BuildCommand_Good_ISOFormat` +- `linuxkit_test.go:76` `TestNewLinuxKitManagerWithHypervisor_Good` +- `linuxkit_test.go:89` `TestLinuxKitManager_Run_Good_Detached` +- `linuxkit_test.go:128` `TestLinuxKitManager_Run_Good_DefaultValues` +- `linuxkit_test.go:153` `TestLinuxKitManager_Run_Bad_ImageNotFound` +- `linuxkit_test.go:164` `TestLinuxKitManager_Run_Bad_UnsupportedFormat` +- `linuxkit_test.go:204` `TestLinuxKitManager_Stop_Bad_NotFound` +- `linuxkit_test.go:214` `TestLinuxKitManager_Stop_Bad_NotRunning` +- `linuxkit_test.go:251` `TestLinuxKitManager_List_Good_VerifiesRunningStatus` +- `linuxkit_test.go:303` `TestLinuxKitManager_Logs_Bad_NotFound` +- `linuxkit_test.go:313` `TestLinuxKitManager_Logs_Bad_NoLogFile` +- `linuxkit_test.go:336` `TestLinuxKitManager_Exec_Bad_NotFound` +- `linuxkit_test.go:346` `TestLinuxKitManager_Exec_Bad_NotRunning` +- `linuxkit_test.go:359` `TestDetectImageFormat_Good` +- `linuxkit_test.go:381` `TestDetectImageFormat_Bad_Unknown` +- `linuxkit_test.go:429` `TestLinuxKitManager_Logs_Good_Follow` +- `linuxkit_test.go:467` `TestFollowReader_Read_Good_WithData` +- `linuxkit_test.go:500` `TestFollowReader_Read_Good_ContextCancel` +- `linuxkit_test.go:544` `TestNewFollowReader_Bad_FileNotFound` +- `linuxkit_test.go:551` `TestLinuxKitManager_Run_Bad_BuildCommandError` +- `linuxkit_test.go:570` `TestLinuxKitManager_Run_Good_Foreground` +- `linuxkit_test.go:598` `TestLinuxKitManager_Stop_Good_ContextCancelled` +- `linuxkit_test.go:635` `TestIsProcessRunning_Good_ExistingProcess` +- `linuxkit_test.go:641` `TestIsProcessRunning_Bad_NonexistentProcess` +- `linuxkit_test.go:647` `TestLinuxKitManager_Run_Good_WithPortsAndVolumes` +- `linuxkit_test.go:676` `TestFollowReader_Read_Bad_ReaderError` +- `linuxkit_test.go:697` `TestLinuxKitManager_Run_Bad_StartError` +- `linuxkit_test.go:718` `TestLinuxKitManager_Run_Bad_ForegroundStartError` +- `linuxkit_test.go:739` `TestLinuxKitManager_Run_Good_ForegroundWithError` +- `linuxkit_test.go:762` `TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning` +- `sources/cdn_test.go:16` `TestCDNSource_Good_Available` +- `sources/cdn_test.go:26` `TestCDNSource_Bad_NoURL` +- `sources/cdn_test.go:118` `TestCDNSource_LatestVersion_Bad_NoManifest` +- `sources/cdn_test.go:134` `TestCDNSource_LatestVersion_Bad_ServerError` +- `sources/cdn_test.go:150` `TestCDNSource_Download_Good_NoProgress` +- `sources/cdn_test.go:174` `TestCDNSource_Download_Good_LargeFile` +- `sources/cdn_test.go:206` `TestCDNSource_Download_Bad_HTTPErrorCodes` +- `sources/cdn_test.go:238` `TestCDNSource_InterfaceCompliance` +- `sources/cdn_test.go:243` `TestCDNSource_Config` +- `sources/cdn_test.go:254` `TestNewCDNSource_Good` +- `sources/cdn_test.go:268` `TestCDNSource_Download_Good_CreatesDestDir` +- `sources/cdn_test.go:294` `TestSourceConfig_Struct` +- `sources/github_test.go:9` `TestGitHubSource_Good_Available` +- `sources/github_test.go:23` `TestGitHubSource_Name` +- `sources/github_test.go:28` `TestGitHubSource_Config` +- `sources/github_test.go:40` `TestGitHubSource_Good_Multiple` +- `sources/github_test.go:51` `TestNewGitHubSource_Good` +- `sources/github_test.go:65` `TestGitHubSource_InterfaceCompliance` +- `sources/source_test.go:9` `TestSourceConfig_Empty` +- `sources/source_test.go:17` `TestSourceConfig_Complete` +- `sources/source_test.go:31` `TestImageSource_Interface` +- `state_test.go:13` `TestNewState_Good` +- `state_test.go:21` `TestLoadState_Good_NewFile` +- `state_test.go:33` `TestLoadState_Good_ExistingFile` +- `state_test.go:64` `TestLoadState_Bad_InvalidJSON` +- `state_test.go:142` `TestState_Get_Bad_NotFound` +- `state_test.go:162` `TestState_SaveState_Good_CreatesDirectory` +- `state_test.go:177` `TestDefaultStateDir_Good` +- `state_test.go:183` `TestDefaultStatePath_Good` +- `state_test.go:189` `TestDefaultLogsDir_Good` +- `state_test.go:195` `TestLogPath_Good` +- `state_test.go:201` `TestEnsureLogsDir_Good` +- `state_test.go:211` `TestGenerateID_Good` +- `templates_test.go:13` `TestListTemplates_Good` +- `templates_test.go:44` `TestGetTemplate_Good_CoreDev` +- `templates_test.go:55` `TestGetTemplate_Good_ServerPhp` +- `templates_test.go:66` `TestGetTemplate_Bad_NotFound` +- `templates_test.go:73` `TestApplyVariables_Good_SimpleSubstitution` +- `templates_test.go:86` `TestApplyVariables_Good_WithDefaults` +- `templates_test.go:99` `TestApplyVariables_Good_AllDefaults` +- `templates_test.go:109` `TestApplyVariables_Good_MixedSyntax` +- `templates_test.go:128` `TestApplyVariables_Good_EmptyDefault` +- `templates_test.go:138` `TestApplyVariables_Bad_MissingRequired` +- `templates_test.go:149` `TestApplyVariables_Bad_MultipleMissing` +- `templates_test.go:164` `TestApplyTemplate_Good` +- `templates_test.go:178` `TestApplyTemplate_Bad_TemplateNotFound` +- `templates_test.go:189` `TestApplyTemplate_Bad_MissingVariable` +- `templates_test.go:199` `TestExtractVariables_Good` +- `templates_test.go:221` `TestExtractVariables_Good_NoVariables` +- `templates_test.go:230` `TestExtractVariables_Good_OnlyDefaults` +- `templates_test.go:241` `TestScanUserTemplates_Good` +- `templates_test.go:265` `TestScanUserTemplates_Good_MultipleTemplates` +- `templates_test.go:287` `TestScanUserTemplates_Good_EmptyDirectory` +- `templates_test.go:295` `TestScanUserTemplates_Bad_NonexistentDirectory` +- `templates_test.go:301` `TestExtractTemplateDescription_Good` +- `templates_test.go:318` `TestExtractTemplateDescription_Good_NoComments` +- `templates_test.go:333` `TestExtractTemplateDescription_Bad_FileNotFound` +- `templates_test.go:339` `TestVariablePatternEdgeCases_Good` +- `templates_test.go:387` `TestScanUserTemplates_Good_SkipsBuiltinNames` +- `templates_test.go:405` `TestScanUserTemplates_Good_SkipsDirectories` +- `templates_test.go:422` `TestScanUserTemplates_Good_YamlExtension` +- `templates_test.go:443` `TestExtractTemplateDescription_Good_EmptyComment` +- `templates_test.go:461` `TestExtractTemplateDescription_Good_MultipleEmptyComments` +- `templates_test.go:481` `TestScanUserTemplates_Good_DefaultDescription` + +## 4. Exported Functions Missing Usage-Example Doc Comments + +- Total exported functions missing a usage example marker: 38 +- Note: every function listed below has a doc comment, but none of the comments include an obvious usage example marker such as `Usage:` or `Example:`. + +- `cmd/vm/cmd_templates.go:150` `RunFromTemplate` (missing usage example) +- `cmd/vm/cmd_templates.go:296` `ParseVarFlags` (missing usage example) +- `cmd/vm/cmd_vm.go:28` `AddVMCommands` (missing usage example) +- `container.go:84` `GenerateID` (missing usage example) +- `devenv/config.go:41` `DefaultConfig` (missing usage example) +- `devenv/config.go:57` `ConfigPath` (missing usage example) +- `devenv/config.go:67` `LoadConfig` (missing usage example) +- `devenv/devops.go:31` `New` (missing usage example) +- `devenv/devops.go:56` `ImageName` (missing usage example) +- `devenv/devops.go:61` `ImagesDir` (missing usage example) +- `devenv/devops.go:73` `ImagePath` (missing usage example) +- `devenv/devops.go:109` `DefaultBootOptions` (missing usage example) +- `devenv/images.go:40` `NewImageManager` (missing usage example) +- `devenv/serve.go:75` `DetectServeCommand` (missing usage example) +- `devenv/test.go:75` `DetectTestCommand` (missing usage example) +- `devenv/test.go:115` `LoadTestConfig` (missing usage example) +- `hypervisor.go:50` `NewQemuHypervisor` (missing usage example) +- `hypervisor.go:155` `NewHyperkitHypervisor` (missing usage example) +- `hypervisor.go:222` `DetectImageFormat` (missing usage example) +- `hypervisor.go:239` `DetectHypervisor` (missing usage example) +- `hypervisor.go:258` `GetHypervisor` (missing usage example) +- `linuxkit.go:25` `NewLinuxKitManager` (missing usage example) +- `linuxkit.go:49` `NewLinuxKitManagerWithHypervisor` (missing usage example) +- `sources/cdn.go:24` `NewCDNSource` (missing usage example) +- `sources/github.go:22` `NewGitHubSource` (missing usage example) +- `state.go:22` `DefaultStateDir` (missing usage example) +- `state.go:31` `DefaultStatePath` (missing usage example) +- `state.go:40` `DefaultLogsDir` (missing usage example) +- `state.go:49` `NewState` (missing usage example) +- `state.go:58` `LoadState` (missing usage example) +- `state.go:157` `LogPath` (missing usage example) +- `state.go:166` `EnsureLogsDir` (missing usage example) +- `templates.go:47` `ListTemplates` (missing usage example) +- `templates.go:52` `ListTemplatesIter` (missing usage example) +- `templates.go:75` `GetTemplate` (missing usage example) +- `templates.go:107` `ApplyTemplate` (missing usage example) +- `templates.go:120` `ApplyVariables` (missing usage example) +- `templates.go:169` `ExtractVariables` (missing usage example) + +## Risk Notes + +- Breaking-change surface is moderate because this repo is consumed by two modules: `core` and `go-devops`. +- The highest-effort part of the upgrade is not the version bump itself; it is the repo-wide removal of banned stdlib imports, especially the current `os/exec` usage across runtime code and tests. +- The doc-comment and test-renaming work is mechanically simple, but it touches many files and will create broad diff surface for downstream review. diff --git a/cmd/vm/cmd_container.go b/cmd/vm/cmd_container.go index 56c3873..d586a3e 100644 --- a/cmd/vm/cmd_container.go +++ b/cmd/vm/cmd_container.go @@ -2,18 +2,17 @@ package vm import ( "context" - "fmt" goio "io" - "os" - "strings" "text/tabwriter" "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/go-container" - "forge.lthn.ai/core/go-i18n" - "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" ) var ( @@ -82,12 +81,12 @@ func runContainer(image, name string, detach bool, memory, cpus, sshPort int) er SSHPort: sshPort, } - fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("image")), image) + core.Print(nil, "%s %s", dimStyle.Render(i18n.Label("image")), image) if name != "" { - fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.name")), name) + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.name")), name) } - fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name()) - fmt.Println() + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name()) + core.Println() ctx := context.Background() c, err := manager.Run(ctx, image, opts) @@ -96,13 +95,14 @@ func runContainer(image, name string, detach bool, memory, cpus, sshPort int) er } if detach { - fmt.Printf("%s %s\n", successStyle.Render(i18n.Label("started")), c.ID) - fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID) - fmt.Println() - fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]})) - fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]})) + core.Print(nil, "%s %s", successStyle.Render(i18n.Label("started")), c.ID) + core.Print(nil, "%s %d", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID) + core.Println() + core.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]})) } else { - fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID) + core.Println() + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID) } return nil @@ -151,16 +151,16 @@ func listContainers(all bool) error { if len(containers) == 0 { if all { - fmt.Println(i18n.T("cmd.vm.ps.no_containers")) + core.Println(i18n.T("cmd.vm.ps.no_containers")) } else { - fmt.Println(i18n.T("cmd.vm.ps.no_running")) + core.Println(i18n.T("cmd.vm.ps.no_running")) } return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(w, i18n.T("cmd.vm.ps.header")) - _, _ = fmt.Fprintln(w, "--\t----\t-----\t------\t-------\t---") + w := tabwriter.NewWriter(proc.Stdout, 0, 0, 2, ' ', 0) + core.Print(w, "%s", i18n.T("cmd.vm.ps.header")) + core.Print(w, "%s", "--\t----\t-----\t------\t-------\t---") for _, c := range containers { // Shorten image path @@ -183,7 +183,7 @@ func listContainers(all bool) error { status = errorStyle.Render(status) } - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", + core.Print(w, "%s\t%s\t%s\t%s\t%s\t%d", c.ID[:8], c.Name, imageName, status, duration, c.PID) } @@ -193,15 +193,15 @@ func listContainers(all bool) error { func formatDuration(d time.Duration) string { if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) + return core.Sprintf("%ds", int(d.Seconds())) } if d < time.Hour { - return fmt.Sprintf("%dm", int(d.Minutes())) + return core.Sprintf("%dm", int(d.Minutes())) } if d < 24*time.Hour { - return fmt.Sprintf("%dh", int(d.Hours())) + return core.Sprintf("%dh", int(d.Hours())) } - return fmt.Sprintf("%dd", int(d.Hours()/24)) + return core.Sprintf("%dd", int(d.Hours()/24)) } // addVMStopCommand adds the 'stop' command under vm. @@ -233,14 +233,14 @@ func stopContainer(id string) error { return err } - fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.stop.stopping")), fullID[:8]) + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.stop.stopping")), fullID[:8]) ctx := context.Background() if err := manager.Stop(ctx, fullID); err != nil { return coreerr.E("stopContainer", i18n.T("i18n.fail.stop", "container"), err) } - fmt.Printf("%s\n", successStyle.Render(i18n.T("common.status.stopped"))) + core.Print(nil, "%s", successStyle.Render(i18n.T("common.status.stopped"))) return nil } @@ -254,7 +254,7 @@ func resolveContainerID(manager *container.LinuxKitManager, partialID string) (s var matches []*container.Container for _, c := range containers { - if strings.HasPrefix(c.ID, partialID) || strings.HasPrefix(c.Name, partialID) { + if core.HasPrefix(c.ID, partialID) || core.HasPrefix(c.Name, partialID) { matches = append(matches, c) } } @@ -308,7 +308,7 @@ func viewLogs(id string, follow bool) error { } defer func() { _ = reader.Close() }() - _, err = goio.Copy(os.Stdout, reader) + _, err = goio.Copy(proc.Stdout, reader) return err } diff --git a/cmd/vm/cmd_templates.go b/cmd/vm/cmd_templates.go index 510f165..95cfb69 100644 --- a/cmd/vm/cmd_templates.go +++ b/cmd/vm/cmd_templates.go @@ -2,18 +2,16 @@ package vm import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" "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/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. @@ -72,29 +70,30 @@ func listTemplates() error { templates := container.ListTemplates() if len(templates) == 0 { - fmt.Println(i18n.T("cmd.vm.templates.no_templates")) + core.Println(i18n.T("cmd.vm.templates.no_templates")) return nil } - fmt.Printf("%s\n\n", repoNameStyle.Render(i18n.T("cmd.vm.templates.title"))) + core.Print(nil, "%s", repoNameStyle.Render(i18n.T("cmd.vm.templates.title"))) + core.Println() - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(w, i18n.T("cmd.vm.templates.header")) - _, _ = fmt.Fprintln(w, "----\t-----------") + w := tabwriter.NewWriter(proc.Stdout, 0, 0, 2, ' ', 0) + core.Print(w, "%s", i18n.T("cmd.vm.templates.header")) + core.Print(w, "%s", "----\t-----------") for _, tmpl := range templates { desc := tmpl.Description if len(desc) > 60 { desc = desc[:57] + "..." } - _, _ = fmt.Fprintf(w, "%s\t%s\n", repoNameStyle.Render(tmpl.Name), desc) + core.Print(w, "%s\t%s", repoNameStyle.Render(tmpl.Name), desc) } _ = w.Flush() - fmt.Println() - fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.show"), dimStyle.Render("core vm templates show ")) - fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.vars"), dimStyle.Render("core vm templates vars ")) - fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.run"), dimStyle.Render("core vm run --template --var SSH_KEY=\"...\"")) + core.Println() + core.Print(nil, "%s %s", i18n.T("cmd.vm.templates.hint.show"), dimStyle.Render("core vm templates show ")) + core.Print(nil, "%s %s", i18n.T("cmd.vm.templates.hint.vars"), dimStyle.Render("core vm templates vars ")) + core.Print(nil, "%s %s", i18n.T("cmd.vm.templates.hint.run"), dimStyle.Render("core vm run --template --var SSH_KEY=\"...\"")) return nil } @@ -105,8 +104,9 @@ func showTemplate(name string) error { return err } - fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name)) - fmt.Println(content) + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name)) + core.Println() + core.Println(content) return nil } @@ -119,34 +119,39 @@ func showTemplateVars(name string) error { required, optional := container.ExtractVariables(content) - fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name)) + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name)) + core.Println() if len(required) > 0 { - fmt.Printf("%s\n", errorStyle.Render(i18n.T("cmd.vm.templates.vars.required"))) + core.Print(nil, "%s", errorStyle.Render(i18n.T("cmd.vm.templates.vars.required"))) for _, v := range required { - fmt.Printf(" %s\n", varStyle.Render("${"+v+"}")) + core.Print(nil, " %s", varStyle.Render("${"+v+"}")) } - fmt.Println() + core.Println() } if len(optional) > 0 { - fmt.Printf("%s\n", successStyle.Render(i18n.T("cmd.vm.templates.vars.optional"))) + core.Print(nil, "%s", successStyle.Render(i18n.T("cmd.vm.templates.vars.optional"))) for v, def := range optional { - fmt.Printf(" %s = %s\n", + core.Print(nil, " %s = %s", varStyle.Render("${"+v+"}"), defaultStyle.Render(def)) } - fmt.Println() + core.Println() } if len(required) == 0 && len(optional) == 0 { - fmt.Println(i18n.T("cmd.vm.templates.vars.none")) + core.Println(i18n.T("cmd.vm.templates.vars.none")) } return nil } // 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 { // Apply template with variables content, err := container.ApplyTemplate(templateName, vars) @@ -155,23 +160,23 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai } // Create a temporary directory for the build - tmpDir, err := os.MkdirTemp("", "core-linuxkit-*") + tmpDir, err := coreutil.MkdirTemp("core-linuxkit-") if err != nil { return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "create temp directory"}), err) } - defer func() { _ = os.RemoveAll(tmpDir) }() + defer func() { _ = io.Local.DeleteAll(tmpDir) }() // Write the YAML file - yamlPath := filepath.Join(tmpDir, templateName+".yml") + yamlPath := coreutil.JoinPath(tmpDir, core.Concat(templateName, ".yml")) 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) } - fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(templateName)) - fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.building")), yamlPath) + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(templateName)) + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.building")), yamlPath) // Build the image using linuxkit - outputPath := filepath.Join(tmpDir, templateName) + outputPath := coreutil.JoinPath(tmpDir, templateName) if err := buildLinuxKitImage(yamlPath, outputPath); err != nil { return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "build image"}), err) } @@ -182,8 +187,8 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai return coreerr.E("RunFromTemplate", i18n.T("cmd.vm.error.no_image_found"), nil) } - fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("common.label.image")), imagePath) - fmt.Println() + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("common.label.image")), imagePath) + core.Println() // Run the image manager, err := container.NewLinuxKitManager(io.Local) @@ -191,8 +196,8 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai return coreerr.E("RunFromTemplate", i18n.T("common.error.failed", map[string]any{"Action": "initialize container manager"}), err) } - fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name()) - fmt.Println() + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name()) + core.Println() ctx := context.Background() c, err := manager.Run(ctx, imagePath, runOpts) @@ -201,13 +206,14 @@ func RunFromTemplate(templateName string, vars map[string]string, runOpts contai } if runOpts.Detach { - fmt.Printf("%s %s\n", successStyle.Render(i18n.T("common.label.started")), c.ID) - fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID) - fmt.Println() - fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]any{"ID": c.ID[:8]})) - fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]any{"ID": c.ID[:8]})) + core.Print(nil, "%s %s", successStyle.Render(i18n.T("common.label.started")), c.ID) + core.Print(nil, "%s %d", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID) + core.Println() + core.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]})) } else { - fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID) + core.Println() + core.Print(nil, "%s %s", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID) } return nil @@ -223,13 +229,13 @@ func buildLinuxKitImage(yamlPath, outputPath string) error { // Build the image // linuxkit build --format iso-bios --name - cmd := exec.Command(lkPath, "build", + cmd := proc.NewCommand(lkPath, "build", "--format", "iso-bios", "--name", outputPath, yamlPath) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stdout = proc.Stdout + cmd.Stderr = proc.Stderr return cmd.Run() } @@ -240,27 +246,27 @@ func findBuiltImage(basePath string) string { extensions := []string{".iso", "-bios.iso", ".qcow2", ".raw", ".vmdk"} for _, ext := range extensions { - path := basePath + ext - if _, err := os.Stat(path); err == nil { + path := core.Concat(basePath, ext) + if io.Local.IsFile(path) { return path } } // Check directory for any image file - dir := filepath.Dir(basePath) - base := filepath.Base(basePath) + dir := core.PathDir(basePath) + base := core.PathBase(basePath) - entries, err := os.ReadDir(dir) + entries, err := io.Local.List(dir) if err != nil { return "" } for _, entry := range entries { name := entry.Name() - if strings.HasPrefix(name, base) { + if core.HasPrefix(name, base) { for _, ext := range []string{".iso", ".qcow2", ".raw", ".vmdk"} { - if strings.HasSuffix(name, ext) { - return filepath.Join(dir, name) + if core.HasSuffix(name, ext) { + return coreutil.JoinPath(dir, name) } } } @@ -272,7 +278,7 @@ func findBuiltImage(basePath string) string { // lookupLinuxKit finds the linuxkit binary. func lookupLinuxKit() (string, error) { // Check PATH first - if path, err := exec.LookPath("linuxkit"); err == nil { + if path, err := proc.LookPath("linuxkit"); err == nil { return path, nil } @@ -283,7 +289,7 @@ func lookupLinuxKit() (string, error) { } for _, p := range paths { - if _, err := os.Stat(p); err == nil { + if io.Local.Exists(p) { return p, nil } } @@ -293,19 +299,36 @@ func lookupLinuxKit() (string, error) { // ParseVarFlags parses --var flags into a map. // Format: --var KEY=VALUE or --var KEY="VALUE" +// +// Usage: +// +// vars := ParseVarFlags([]string{"SSH_KEY=abc", "PORT=2222"}) func ParseVarFlags(varFlags []string) map[string]string { vars := make(map[string]string) for _, v := range varFlags { - parts := strings.SplitN(v, "=", 2) + parts := core.SplitN(v, "=", 2) if len(parts) == 2 { - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) + key := core.Trim(parts[0]) + value := core.Trim(parts[1]) // Remove surrounding quotes if present - value = strings.Trim(value, "\"'") + value = stripWrappingQuotes(value) vars[key] = value } } 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 +} diff --git a/cmd/vm/cmd_vm.go b/cmd/vm/cmd_vm.go index d5a00fb..307718c 100644 --- a/cmd/vm/cmd_vm.go +++ b/cmd/vm/cmd_vm.go @@ -2,8 +2,8 @@ package vm import ( + "dappco.re/go/core/i18n" "forge.lthn.ai/core/cli/pkg/cli" - "forge.lthn.ai/core/go-i18n" ) func init() { @@ -25,6 +25,10 @@ var ( ) // AddVMCommands adds container-related commands under 'vm' to the CLI. +// +// Usage: +// +// AddVMCommands(root) func AddVMCommands(root *cli.Command) { vmCmd := &cli.Command{ Use: "vm", diff --git a/container.go b/container.go index d7161c3..5c3dfae 100644 --- a/container.go +++ b/container.go @@ -81,6 +81,10 @@ type Manager interface { } // GenerateID creates a new unique container ID (8 hex characters). +// +// Usage: +// +// id, err := GenerateID() func GenerateID() (string, error) { bytes := make([]byte, 4) if _, err := rand.Read(bytes); err != nil { diff --git a/devenv/claude.go b/devenv/claude.go index 9744bd4..2b995c1 100644 --- a/devenv/claude.go +++ b/devenv/claude.go @@ -2,14 +2,13 @@ package devenv import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/coreutil" + "dappco.re/go/core/container/internal/proc" ) // ClaudeOptions configures the Claude sandbox session. @@ -27,7 +26,7 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio return err } if !running { - fmt.Println("Dev environment not running, booting...") + core.Println("Dev environment not running, booting...") if err := d.Boot(ctx, DefaultBootOptions()); err != nil { return coreerr.E("DevOps.Claude", "failed to boot", err) } @@ -50,20 +49,22 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio for _, auth := range authTypes { switch auth { case "anthropic": - if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" { - envVars = append(envVars, "ANTHROPIC_API_KEY="+key) + if key := core.Env("ANTHROPIC_API_KEY"); key != "" { + envVars = append(envVars, core.Concat("ANTHROPIC_API_KEY=", key)) } case "git": // Forward git config - name, _ := exec.Command("git", "config", "user.name").Output() - email, _ := exec.Command("git", "config", "user.email").Output() + name, _ := proc.NewCommand("git", "config", "user.name").Output() + email, _ := proc.NewCommand("git", "config", "user.email").Output() if len(name) > 0 { - envVars = append(envVars, "GIT_AUTHOR_NAME="+strings.TrimSpace(string(name))) - envVars = append(envVars, "GIT_COMMITTER_NAME="+strings.TrimSpace(string(name))) + trimmed := core.Trim(string(name)) + envVars = append(envVars, core.Concat("GIT_AUTHOR_NAME=", trimmed)) + envVars = append(envVars, core.Concat("GIT_COMMITTER_NAME=", trimmed)) } if len(email) > 0 { - envVars = append(envVars, "GIT_AUTHOR_EMAIL="+strings.TrimSpace(string(email))) - envVars = append(envVars, "GIT_COMMITTER_EMAIL="+strings.TrimSpace(string(email))) + trimmed := core.Trim(string(email)) + envVars = append(envVars, core.Concat("GIT_AUTHOR_EMAIL=", trimmed)) + envVars = append(envVars, core.Concat("GIT_COMMITTER_EMAIL=", trimmed)) } } } @@ -75,7 +76,7 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio "-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "LogLevel=ERROR", "-A", // SSH agent forwarding - "-p", fmt.Sprintf("%d", DefaultSSHPort), + "-p", core.Sprintf("%d", DefaultSSHPort), } args = append(args, "root@localhost") @@ -88,23 +89,20 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio args = append(args, claudeCmd) // Set environment for SSH - cmd := exec.CommandContext(ctx, "ssh", args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd := proc.NewCommandContext(ctx, "ssh", args...) + cmd.Stdin = proc.Stdin + cmd.Stdout = proc.Stdout + cmd.Stderr = proc.Stderr // Pass environment variables through SSH - for _, env := range envVars { - parts := strings.SplitN(env, "=", 2) - if len(parts) == 2 { - cmd.Env = append(os.Environ(), env) - } + if len(envVars) > 0 { + cmd.Env = append(proc.Environ(), envVars...) } - fmt.Println("Starting Claude in sandboxed environment...") - fmt.Println("Project mounted at /app") - fmt.Println("Auth forwarded: SSH agent" + formatAuthList(opts)) - fmt.Println() + core.Println("Starting Claude in sandboxed environment...") + core.Println("Project mounted at /app") + core.Println(core.Concat("Auth forwarded: SSH agent", formatAuthList(opts))) + core.Println() return cmd.Run() } @@ -116,27 +114,27 @@ func formatAuthList(opts ClaudeOptions) string { if len(opts.Auth) == 0 { return ", gh, anthropic, git" } - return ", " + strings.Join(opts.Auth, ", ") + return core.Concat(", ", core.Join(", ", opts.Auth...)) } // CopyGHAuth copies GitHub CLI auth to the VM. func (d *DevOps) CopyGHAuth(ctx context.Context) error { - home, err := os.UserHomeDir() - if err != nil { - return err + home := coreutil.HomeDir() + if home == "" { + return coreerr.E("DevOps.CopyGHAuth", "home directory not available", nil) } - ghConfigDir := filepath.Join(home, ".config", "gh") + ghConfigDir := coreutil.JoinPath(home, ".config", "gh") if !io.Local.IsDir(ghConfigDir) { return nil // No gh config to copy } // Use scp to copy gh config - cmd := exec.CommandContext(ctx, "scp", + cmd := proc.NewCommandContext(ctx, "scp", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "LogLevel=ERROR", - "-P", fmt.Sprintf("%d", DefaultSSHPort), + "-P", core.Sprintf("%d", DefaultSSHPort), "-r", ghConfigDir, "root@localhost:/root/.config/", ) diff --git a/devenv/claude_test.go b/devenv/claude_test.go index 179ef6c..02b4fa8 100644 --- a/devenv/claude_test.go +++ b/devenv/claude_test.go @@ -6,14 +6,14 @@ import ( "github.com/stretchr/testify/assert" ) -func TestClaudeOptions_Default(t *testing.T) { +func TestClaudeOptions_Default_Good(t *testing.T) { opts := ClaudeOptions{} assert.False(t, opts.NoAuth) assert.Nil(t, opts.Auth) assert.Empty(t, opts.Model) } -func TestClaudeOptions_Custom(t *testing.T) { +func TestClaudeOptions_Custom_Good(t *testing.T) { opts := ClaudeOptions{ NoAuth: true, Auth: []string{"gh", "anthropic"}, @@ -24,19 +24,19 @@ func TestClaudeOptions_Custom(t *testing.T) { assert.Equal(t, "opus", opts.Model) } -func TestFormatAuthList_Good_NoAuth(t *testing.T) { +func TestFormatAuthList_NoAuth_Good(t *testing.T) { opts := ClaudeOptions{NoAuth: true} result := formatAuthList(opts) assert.Equal(t, " (none)", result) } -func TestFormatAuthList_Good_Default(t *testing.T) { +func TestFormatAuthList_Default_Good(t *testing.T) { opts := ClaudeOptions{} result := formatAuthList(opts) assert.Equal(t, ", gh, anthropic, git", result) } -func TestFormatAuthList_Good_CustomAuth(t *testing.T) { +func TestFormatAuthList_CustomAuth_Good(t *testing.T) { opts := ClaudeOptions{ Auth: []string{"gh"}, } @@ -44,7 +44,7 @@ func TestFormatAuthList_Good_CustomAuth(t *testing.T) { assert.Equal(t, ", gh", result) } -func TestFormatAuthList_Good_MultipleAuth(t *testing.T) { +func TestFormatAuthList_MultipleAuth_Good(t *testing.T) { opts := ClaudeOptions{ Auth: []string{"gh", "ssh", "git"}, } @@ -52,7 +52,7 @@ func TestFormatAuthList_Good_MultipleAuth(t *testing.T) { assert.Equal(t, ", gh, ssh, git", result) } -func TestFormatAuthList_Good_EmptyAuth(t *testing.T) { +func TestFormatAuthList_EmptyAuth_Good(t *testing.T) { opts := ClaudeOptions{ Auth: []string{}, } diff --git a/devenv/config.go b/devenv/config.go index 9f33dd6..ebcfcfe 100644 --- a/devenv/config.go +++ b/devenv/config.go @@ -1,11 +1,12 @@ package devenv import ( - "os" - "path/filepath" - + "dappco.re/go/core/io" "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. @@ -38,6 +39,10 @@ type CDNConfig struct { } // DefaultConfig returns sensible defaults. +// +// Usage: +// +// cfg := DefaultConfig() func DefaultConfig() *Config { return &Config{ Version: 1, @@ -54,16 +59,24 @@ func DefaultConfig() *Config { } // ConfigPath returns the path to the config file. +// +// Usage: +// +// path, err := ConfigPath() func ConfigPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err + home := coreutil.HomeDir() + if home == "" { + return "", core.E("ConfigPath", "home directory not available", nil) } - return filepath.Join(home, ".core", "config.yaml"), nil + return coreutil.JoinPath(home, ".core", "config.yaml"), nil } // LoadConfig loads configuration from ~/.core/config.yaml using the provided medium. // Returns default config if file doesn't exist. +// +// Usage: +// +// cfg, err := LoadConfig(io.Local) func LoadConfig(m io.Medium) (*Config, error) { configPath, err := ConfigPath() if err != nil { diff --git a/devenv/config_test.go b/devenv/config_test.go index 863d819..f3f326d 100644 --- a/devenv/config_test.go +++ b/devenv/config_test.go @@ -1,35 +1,33 @@ package devenv import ( - "os" - "path/filepath" + "syscall" "testing" - "forge.lthn.ai/core/go-io" + "dappco.re/go/core/container/internal/coreutil" + "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestDefaultConfig(t *testing.T) { +func TestConfig_DefaultConfig_Good(t *testing.T) { cfg := DefaultConfig() assert.Equal(t, 1, cfg.Version) assert.Equal(t, "auto", cfg.Images.Source) assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo) } -func TestConfigPath(t *testing.T) { +func TestConfig_ConfigPath_Good(t *testing.T) { path, err := ConfigPath() assert.NoError(t, err) assert.Contains(t, path, ".core/config.yaml") } -func TestLoadConfig_Good(t *testing.T) { +func TestConfig_LoadConfig_Good(t *testing.T) { t.Run("returns default if not exists", func(t *testing.T) { // Mock HOME to a temp dir tempHome := t.TempDir() - origHome := os.Getenv("HOME") t.Setenv("HOME", tempHome) - defer func() { _ = os.Setenv("HOME", origHome) }() cfg, err := LoadConfig(io.Local) assert.NoError(t, err) @@ -40,8 +38,8 @@ func TestLoadConfig_Good(t *testing.T) { tempHome := t.TempDir() t.Setenv("HOME", tempHome) - coreDir := filepath.Join(tempHome, ".core") - err := os.MkdirAll(coreDir, 0755) + coreDir := coreutil.JoinPath(tempHome, ".core") + err := io.Local.EnsureDir(coreDir) require.NoError(t, err) configData := ` @@ -51,7 +49,7 @@ images: cdn: url: https://cdn.example.com ` - err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) + err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), configData) require.NoError(t, err) cfg, err := LoadConfig(io.Local) @@ -62,16 +60,16 @@ images: }) } -func TestLoadConfig_Bad(t *testing.T) { +func TestConfig_LoadConfig_Bad(t *testing.T) { t.Run("invalid yaml", func(t *testing.T) { tempHome := t.TempDir() t.Setenv("HOME", tempHome) - coreDir := filepath.Join(tempHome, ".core") - err := os.MkdirAll(coreDir, 0755) + coreDir := coreutil.JoinPath(tempHome, ".core") + err := io.Local.EnsureDir(coreDir) require.NoError(t, err) - err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte("invalid: yaml: :"), 0644) + err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), "invalid: yaml: :") require.NoError(t, err) _, err = LoadConfig(io.Local) @@ -79,7 +77,7 @@ func TestLoadConfig_Bad(t *testing.T) { }) } -func TestConfig_Struct(t *testing.T) { +func TestConfig_Struct_Good(t *testing.T) { cfg := &Config{ Version: 2, Images: ImagesConfig{ @@ -102,7 +100,7 @@ func TestConfig_Struct(t *testing.T) { assert.Equal(t, "https://cdn.example.com", cfg.Images.CDN.URL) } -func TestDefaultConfig_Complete(t *testing.T) { +func TestDefaultConfig_Complete_Good(t *testing.T) { cfg := DefaultConfig() assert.Equal(t, 1, cfg.Version) assert.Equal(t, "auto", cfg.Images.Source) @@ -111,12 +109,12 @@ func TestDefaultConfig_Complete(t *testing.T) { assert.Empty(t, cfg.Images.CDN.URL) } -func TestLoadConfig_Good_PartialConfig(t *testing.T) { +func TestLoadConfig_PartialConfig_Good(t *testing.T) { tempHome := t.TempDir() t.Setenv("HOME", tempHome) - coreDir := filepath.Join(tempHome, ".core") - err := os.MkdirAll(coreDir, 0755) + coreDir := coreutil.JoinPath(tempHome, ".core") + err := io.Local.EnsureDir(coreDir) require.NoError(t, err) // Config only specifies source, should merge with defaults @@ -125,7 +123,7 @@ version: 1 images: source: github ` - err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) + err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), configData) require.NoError(t, err) cfg, err := LoadConfig(io.Local) @@ -136,7 +134,7 @@ images: assert.Equal(t, "host-uk/core-images", cfg.Images.GitHub.Repo) } -func TestLoadConfig_Good_AllSourceTypes(t *testing.T) { +func TestLoadConfig_AllSourceTypes_Good(t *testing.T) { tests := []struct { name string config string @@ -191,11 +189,11 @@ images: tempHome := t.TempDir() t.Setenv("HOME", tempHome) - coreDir := filepath.Join(tempHome, ".core") - err := os.MkdirAll(coreDir, 0755) + coreDir := coreutil.JoinPath(tempHome, ".core") + err := io.Local.EnsureDir(coreDir) require.NoError(t, err) - err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(tt.config), 0644) + err = io.Local.Write(coreutil.JoinPath(coreDir, "config.yaml"), tt.config) require.NoError(t, err) cfg, err := LoadConfig(io.Local) @@ -205,7 +203,7 @@ images: } } -func TestImagesConfig_Struct(t *testing.T) { +func TestImagesConfig_Struct_Good(t *testing.T) { ic := ImagesConfig{ Source: "auto", GitHub: GitHubConfig{Repo: "test/repo"}, @@ -214,42 +212,42 @@ func TestImagesConfig_Struct(t *testing.T) { assert.Equal(t, "test/repo", ic.GitHub.Repo) } -func TestGitHubConfig_Struct(t *testing.T) { +func TestGitHubConfig_Struct_Good(t *testing.T) { gc := GitHubConfig{Repo: "owner/repo"} assert.Equal(t, "owner/repo", gc.Repo) } -func TestRegistryConfig_Struct(t *testing.T) { +func TestRegistryConfig_Struct_Good(t *testing.T) { rc := RegistryConfig{Image: "ghcr.io/owner/image:latest"} assert.Equal(t, "ghcr.io/owner/image:latest", rc.Image) } -func TestCDNConfig_Struct(t *testing.T) { +func TestCDNConfig_Struct_Good(t *testing.T) { cc := CDNConfig{URL: "https://cdn.example.com/images"} assert.Equal(t, "https://cdn.example.com/images", cc.URL) } -func TestLoadConfig_Bad_UnreadableFile(t *testing.T) { +func TestLoadConfig_UnreadableFile_Bad(t *testing.T) { // This test is platform-specific and may not work on all systems // Skip if we can't test file permissions properly - if os.Getuid() == 0 { + if syscall.Getuid() == 0 { t.Skip("Skipping permission test when running as root") } tempHome := t.TempDir() t.Setenv("HOME", tempHome) - coreDir := filepath.Join(tempHome, ".core") - err := os.MkdirAll(coreDir, 0755) + coreDir := coreutil.JoinPath(tempHome, ".core") + err := io.Local.EnsureDir(coreDir) require.NoError(t, err) - configPath := filepath.Join(coreDir, "config.yaml") - err = os.WriteFile(configPath, []byte("version: 1"), 0000) + configPath := coreutil.JoinPath(coreDir, "config.yaml") + err = io.Local.WriteMode(configPath, "version: 1", 0000) require.NoError(t, err) _, err = LoadConfig(io.Local) assert.Error(t, err) // Restore permissions so cleanup works - _ = os.Chmod(configPath, 0644) + _ = syscall.Chmod(configPath, 0644) } diff --git a/devenv/devops.go b/devenv/devops.go index 82bc891..fc721c7 100644 --- a/devenv/devops.go +++ b/devenv/devops.go @@ -3,15 +3,15 @@ package devenv import ( "context" - "fmt" - "os" - "path/filepath" "runtime" "time" - "forge.lthn.ai/core/go-container" - "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + "dappco.re/go/core/container" + "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/coreutil" ) const ( @@ -28,6 +28,10 @@ type DevOps struct { } // New creates a new DevOps instance using the provided medium. +// +// Usage: +// +// dev, err := New(io.Local) func New(m io.Medium) (*DevOps, error) { cfg, err := LoadConfig(m) if err != nil { @@ -53,29 +57,41 @@ func New(m io.Medium) (*DevOps, error) { } // ImageName returns the platform-specific image name. +// +// Usage: +// +// name := ImageName() func ImageName() string { - return fmt.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH) + return core.Sprintf("core-devops-%s-%s.qcow2", runtime.GOOS, runtime.GOARCH) } // ImagesDir returns the path to the images directory. +// +// Usage: +// +// dir, err := ImagesDir() func ImagesDir() (string, error) { - if dir := os.Getenv("CORE_IMAGES_DIR"); dir != "" { + if dir := core.Env("CORE_IMAGES_DIR"); dir != "" { return dir, nil } - home, err := os.UserHomeDir() - if err != nil { - return "", err + home := coreutil.HomeDir() + if home == "" { + return "", core.E("ImagesDir", "home directory not available", nil) } - return filepath.Join(home, ".core", "images"), nil + return coreutil.JoinPath(home, ".core", "images"), nil } // ImagePath returns the full path to the platform-specific image. +// +// Usage: +// +// path, err := ImagePath() func ImagePath() (string, error) { dir, err := ImagesDir() if err != nil { return "", err } - return filepath.Join(dir, ImageName()), nil + return coreutil.JoinPath(dir, ImageName()), nil } // IsInstalled checks if the dev image is installed. @@ -106,6 +122,10 @@ type BootOptions struct { } // DefaultBootOptions returns sensible defaults. +// +// Usage: +// +// opts := DefaultBootOptions() func DefaultBootOptions() BootOptions { return BootOptions{ Memory: 4096, diff --git a/devenv/devops_test.go b/devenv/devops_test.go index c0cf745..e72d66b 100644 --- a/devenv/devops_test.go +++ b/devenv/devops_test.go @@ -2,20 +2,29 @@ package devenv import ( "context" - "os" - "os/exec" - "path/filepath" "runtime" + "syscall" "testing" "time" - "forge.lthn.ai/core/go-container" - "forge.lthn.ai/core/go-io" + 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/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestImageName(t *testing.T) { +func newManagedTempDir(t *testing.T, prefix string) string { + t.Helper() + dir, err := coreutil.MkdirTemp(prefix) + require.NoError(t, err) + t.Cleanup(func() { _ = io.Local.DeleteAll(dir) }) + return dir +} + +func TestDevOps_ImageName_Good(t *testing.T) { name := ImageName() assert.Contains(t, name, "core-devops-") assert.Contains(t, name, runtime.GOOS) @@ -23,12 +32,9 @@ func TestImageName(t *testing.T) { assert.True(t, (name[len(name)-6:] == ".qcow2")) } -func TestImagesDir(t *testing.T) { +func TestDevOps_ImagesDir_Good(t *testing.T) { t.Run("default directory", func(t *testing.T) { - // Unset env if it exists - orig := os.Getenv("CORE_IMAGES_DIR") - _ = os.Unsetenv("CORE_IMAGES_DIR") - defer func() { _ = os.Setenv("CORE_IMAGES_DIR", orig) }() + t.Setenv("CORE_IMAGES_DIR", "") dir, err := ImagesDir() assert.NoError(t, err) @@ -45,17 +51,17 @@ func TestImagesDir(t *testing.T) { }) } -func TestImagePath(t *testing.T) { +func TestDevOps_ImagePath_Good(t *testing.T) { customDir := "/tmp/images" t.Setenv("CORE_IMAGES_DIR", customDir) path, err := ImagePath() assert.NoError(t, err) - expected := filepath.Join(customDir, ImageName()) + expected := coreutil.JoinPath(customDir, ImageName()) assert.Equal(t, expected, path) } -func TestDefaultBootOptions(t *testing.T) { +func TestDevOps_DefaultBootOptions_Good(t *testing.T) { opts := DefaultBootOptions() assert.Equal(t, 4096, opts.Memory) assert.Equal(t, 2, opts.CPUs) @@ -63,7 +69,7 @@ func TestDefaultBootOptions(t *testing.T) { assert.False(t, opts.Fresh) } -func TestIsInstalled_Bad(t *testing.T) { +func TestDevOps_IsInstalled_Bad(t *testing.T) { t.Run("returns false for non-existent image", func(t *testing.T) { // Point to a temp directory that is empty tempDir := t.TempDir() @@ -75,14 +81,14 @@ func TestIsInstalled_Bad(t *testing.T) { }) } -func TestIsInstalled_Good(t *testing.T) { +func TestDevOps_IsInstalled_Good(t *testing.T) { t.Run("returns true when image exists", func(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) // Create the image file - imagePath := filepath.Join(tempDir, ImageName()) - err := os.WriteFile(imagePath, []byte("fake image data"), 0644) + imagePath := coreutil.JoinPath(tempDir, ImageName()) + err := io.Local.Write(imagePath, "fake image data") require.NoError(t, err) d := &DevOps{medium: io.Local} @@ -94,8 +100,8 @@ type mockHypervisor struct{} func (m *mockHypervisor) Name() string { return "mock" } func (m *mockHypervisor) Available() bool { return true } -func (m *mockHypervisor) BuildCommand(ctx context.Context, image string, opts *container.HypervisorOptions) (*exec.Cmd, error) { - return exec.Command("true"), nil +func (m *mockHypervisor) BuildCommand(ctx context.Context, image string, opts *container.HypervisorOptions) (*proc.Command, error) { + return proc.NewCommand("true"), nil } func TestDevOps_Status_Good(t *testing.T) { @@ -107,7 +113,7 @@ func TestDevOps_Status_Good(t *testing.T) { require.NoError(t, err) // Setup mock container manager - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -122,7 +128,7 @@ func TestDevOps_Status_Good(t *testing.T) { ID: "test-id", Name: "core-dev", Status: container.StatusRunning, - PID: os.Getpid(), // Use our own PID so isProcessRunning returns true + PID: syscall.Getpid(), // Use our own PID so isProcessRunning returns true StartedAt: time.Now().Add(-time.Hour), Memory: 2048, CPUs: 4, @@ -139,7 +145,7 @@ func TestDevOps_Status_Good(t *testing.T) { assert.Equal(t, 4, status.CPUs) } -func TestDevOps_Status_Good_NotInstalled(t *testing.T) { +func TestDevOps_StatusNotInstalled_Good(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -147,7 +153,7 @@ func TestDevOps_Status_Good_NotInstalled(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -165,20 +171,20 @@ func TestDevOps_Status_Good_NotInstalled(t *testing.T) { assert.Equal(t, 2222, status.SSHPort) } -func TestDevOps_Status_Good_NoContainer(t *testing.T) { +func TestDevOps_StatusNoContainer_Good(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) // Create fake image to mark as installed - imagePath := filepath.Join(tempDir, ImageName()) - err := os.WriteFile(imagePath, []byte("fake"), 0644) + imagePath := coreutil.JoinPath(tempDir, ImageName()) + err := io.Local.Write(imagePath, "fake") require.NoError(t, err) cfg := DefaultConfig() mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -204,7 +210,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -218,7 +224,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) { ID: "test-id", Name: "core-dev", Status: container.StatusRunning, - PID: os.Getpid(), + PID: syscall.Getpid(), StartedAt: time.Now(), } err = state.Add(c) @@ -229,7 +235,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) { assert.True(t, running) } -func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) { +func TestDevOps_IsRunningNotRunning_Bad(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -237,7 +243,7 @@ func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -252,7 +258,7 @@ func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) { assert.False(t, running) } -func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) { +func TestDevOps_IsRunningContainerStopped_Bad(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -260,7 +266,7 @@ func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -293,7 +299,7 @@ func TestDevOps_findContainer_Good(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -307,7 +313,7 @@ func TestDevOps_findContainer_Good(t *testing.T) { ID: "test-id", Name: "my-container", Status: container.StatusRunning, - PID: os.Getpid(), + PID: syscall.Getpid(), StartedAt: time.Now(), } err = state.Add(c) @@ -320,7 +326,7 @@ func TestDevOps_findContainer_Good(t *testing.T) { assert.Equal(t, "my-container", found.Name) } -func TestDevOps_findContainer_Bad_NotFound(t *testing.T) { +func TestDevOps_findContainerNotFound_Bad(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -328,7 +334,7 @@ func TestDevOps_findContainer_Bad_NotFound(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -343,7 +349,7 @@ func TestDevOps_findContainer_Bad_NotFound(t *testing.T) { assert.Nil(t, found) } -func TestDevOps_Stop_Bad_NotFound(t *testing.T) { +func TestDevOps_StopNotFound_Bad(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -351,7 +357,7 @@ func TestDevOps_Stop_Bad_NotFound(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -366,7 +372,7 @@ func TestDevOps_Stop_Bad_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "not found") } -func TestBootOptions_Custom(t *testing.T) { +func TestBootOptions_Custom_Good(t *testing.T) { opts := BootOptions{ Memory: 8192, CPUs: 4, @@ -379,7 +385,7 @@ func TestBootOptions_Custom(t *testing.T) { assert.True(t, opts.Fresh) } -func TestDevStatus_Struct(t *testing.T) { +func TestDevStatus_Struct_Good(t *testing.T) { status := DevStatus{ Installed: true, Running: true, @@ -400,7 +406,7 @@ func TestDevStatus_Struct(t *testing.T) { assert.Equal(t, time.Hour, status.Uptime) } -func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) { +func TestDevOps_BootNotInstalled_Bad(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -408,7 +414,7 @@ func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -423,20 +429,20 @@ func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) { assert.Contains(t, err.Error(), "not installed") } -func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) { +func TestDevOps_BootAlreadyRunning_Bad(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) // Create fake image - imagePath := filepath.Join(tempDir, ImageName()) - err := os.WriteFile(imagePath, []byte("fake"), 0644) + imagePath := coreutil.JoinPath(tempDir, ImageName()) + err := io.Local.Write(imagePath, "fake") require.NoError(t, err) cfg := DefaultConfig() mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -451,7 +457,7 @@ func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) { ID: "test-id", Name: "core-dev", Status: container.StatusRunning, - PID: os.Getpid(), + PID: syscall.Getpid(), StartedAt: time.Now(), } err = state.Add(c) @@ -462,13 +468,13 @@ func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) { assert.Contains(t, err.Error(), "already running") } -func TestDevOps_Status_Good_WithImageVersion(t *testing.T) { +func TestDevOps_StatusWithImageVersion_Good(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) // Create fake image - imagePath := filepath.Join(tempDir, ImageName()) - err := os.WriteFile(imagePath, []byte("fake"), 0644) + imagePath := coreutil.JoinPath(tempDir, ImageName()) + err := io.Local.Write(imagePath, "fake") require.NoError(t, err) cfg := DefaultConfig() @@ -481,7 +487,7 @@ func TestDevOps_Status_Good_WithImageVersion(t *testing.T) { Source: "test", } - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -498,7 +504,7 @@ func TestDevOps_Status_Good_WithImageVersion(t *testing.T) { assert.Equal(t, "v1.2.3", status.ImageVersion) } -func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) { +func TestDevOps_findContainerMultipleContainers_Good(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -506,7 +512,7 @@ func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -521,14 +527,14 @@ func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) { ID: "id-1", Name: "container-1", Status: container.StatusRunning, - PID: os.Getpid(), + PID: syscall.Getpid(), StartedAt: time.Now(), } c2 := &container.Container{ ID: "id-2", Name: "container-2", Status: container.StatusRunning, - PID: os.Getpid(), + PID: syscall.Getpid(), StartedAt: time.Now(), } err = state.Add(c1) @@ -543,7 +549,7 @@ func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) { assert.Equal(t, "id-2", found.ID) } -func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) { +func TestDevOps_StatusContainerWithUptime_Good(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -551,7 +557,7 @@ func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -566,7 +572,7 @@ func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) { ID: "test-id", Name: "core-dev", Status: container.StatusRunning, - PID: os.Getpid(), + PID: syscall.Getpid(), StartedAt: startTime, Memory: 4096, CPUs: 2, @@ -580,7 +586,7 @@ func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) { assert.GreaterOrEqual(t, status.Uptime.Hours(), float64(1)) } -func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) { +func TestDevOps_IsRunningDifferentContainerName_Bad(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -588,7 +594,7 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -603,7 +609,7 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) { ID: "test-id", Name: "other-container", Status: container.StatusRunning, - PID: os.Getpid(), + PID: syscall.Getpid(), StartedAt: time.Now(), } err = state.Add(c) @@ -615,23 +621,21 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) { assert.False(t, running) } -func TestDevOps_Boot_Good_FreshFlag(t *testing.T) { +func TestDevOps_BootFreshFlag_Good(t *testing.T) { t.Setenv("CORE_SKIP_SSH_SCAN", "true") - tempDir, err := os.MkdirTemp("", "devops-test-*") - require.NoError(t, err) - t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + tempDir := newManagedTempDir(t, "devops-test-") t.Setenv("CORE_IMAGES_DIR", tempDir) // Create fake image - imagePath := filepath.Join(tempDir, ImageName()) - err = os.WriteFile(imagePath, []byte("fake"), 0644) + imagePath := coreutil.JoinPath(tempDir, ImageName()) + err := io.Local.Write(imagePath, "fake") require.NoError(t, err) cfg := DefaultConfig() mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -665,7 +669,7 @@ func TestDevOps_Boot_Good_FreshFlag(t *testing.T) { assert.NoError(t, err) } -func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) { +func TestDevOps_StopContainerNotRunning_Bad(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -673,7 +677,7 @@ func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) { mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -700,23 +704,21 @@ func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) { assert.Contains(t, err.Error(), "not running") } -func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) { +func TestDevOps_BootFreshWithNoExisting_Good(t *testing.T) { t.Setenv("CORE_SKIP_SSH_SCAN", "true") - tempDir, err := os.MkdirTemp("", "devops-boot-fresh-*") - require.NoError(t, err) - t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + tempDir := newManagedTempDir(t, "devops-boot-fresh-") t.Setenv("CORE_IMAGES_DIR", tempDir) // Create fake image - imagePath := filepath.Join(tempDir, ImageName()) - err = os.WriteFile(imagePath, []byte("fake"), 0644) + imagePath := coreutil.JoinPath(tempDir, ImageName()) + err := io.Local.Write(imagePath, "fake") require.NoError(t, err) cfg := DefaultConfig() mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -738,16 +740,16 @@ func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) { assert.NoError(t, err) } -func TestImageName_Format(t *testing.T) { +func TestImageName_Format_Good(t *testing.T) { name := ImageName() // Check format: core-devops-{os}-{arch}.qcow2 assert.Contains(t, name, "core-devops-") assert.Contains(t, name, runtime.GOOS) assert.Contains(t, name, runtime.GOARCH) - assert.True(t, filepath.Ext(name) == ".qcow2") + assert.True(t, core.PathExt(name) == ".qcow2") } -func TestDevOps_Install_Delegates(t *testing.T) { +func TestDevOps_InstallDelegates_Good(t *testing.T) { // This test verifies the Install method delegates to ImageManager tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -765,7 +767,7 @@ func TestDevOps_Install_Delegates(t *testing.T) { assert.Error(t, err) } -func TestDevOps_CheckUpdate_Delegates(t *testing.T) { +func TestDevOps_CheckUpdateDelegates_Good(t *testing.T) { // This test verifies the CheckUpdate method delegates to ImageManager tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) @@ -783,23 +785,21 @@ func TestDevOps_CheckUpdate_Delegates(t *testing.T) { assert.Error(t, err) } -func TestDevOps_Boot_Good_Success(t *testing.T) { +func TestDevOps_BootSuccess_Good(t *testing.T) { t.Setenv("CORE_SKIP_SSH_SCAN", "true") - tempDir, err := os.MkdirTemp("", "devops-boot-success-*") - require.NoError(t, err) - t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + tempDir := newManagedTempDir(t, "devops-boot-success-") t.Setenv("CORE_IMAGES_DIR", tempDir) // Create fake image - imagePath := filepath.Join(tempDir, ImageName()) - err = os.WriteFile(imagePath, []byte("fake"), 0644) + imagePath := coreutil.JoinPath(tempDir, ImageName()) + err := io.Local.Write(imagePath, "fake") require.NoError(t, err) cfg := DefaultConfig() mgr, err := NewImageManager(io.Local, cfg) require.NoError(t, err) - statePath := filepath.Join(tempDir, "containers.json") + statePath := coreutil.JoinPath(tempDir, "containers.json") state := container.NewState(statePath) h := &mockHypervisor{} cm := container.NewLinuxKitManagerWithHypervisor(io.Local, state, h) @@ -815,7 +815,7 @@ func TestDevOps_Boot_Good_Success(t *testing.T) { assert.NoError(t, err) // Mock hypervisor succeeds } -func TestDevOps_Config(t *testing.T) { +func TestDevOps_Config_Good(t *testing.T) { tempDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tempDir) diff --git a/devenv/images.go b/devenv/images.go index 04a2ea1..375164f 100644 --- a/devenv/images.go +++ b/devenv/images.go @@ -2,15 +2,15 @@ package devenv import ( "context" - "encoding/json" - "fmt" - "os" - "path/filepath" + "io/fs" "time" - "forge.lthn.ai/core/go-container/sources" - "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + "dappco.re/go/core/container/sources" + "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/coreutil" ) // ImageManager handles image downloads and updates. @@ -37,6 +37,10 @@ type ImageInfo struct { } // NewImageManager creates a new image manager. +// +// Usage: +// +// manager, err := NewImageManager(io.Local, cfg) func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) { imagesDir, err := ImagesDir() if err != nil { @@ -49,7 +53,7 @@ func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) { } // Load or create manifest - manifestPath := filepath.Join(imagesDir, "manifest.json") + manifestPath := coreutil.JoinPath(imagesDir, "manifest.json") manifest, err := loadManifest(m, manifestPath) if err != nil { return nil, err @@ -119,7 +123,7 @@ func (m *ImageManager) Install(ctx context.Context, progress func(downloaded, to return coreerr.E("ImageManager.Install", "failed to get latest version", err) } - fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name()) + core.Print(nil, "Downloading %s from %s...", ImageName(), src.Name()) // Download if err := src.Download(ctx, m.medium, imagesDir, progress); err != nil { @@ -174,14 +178,15 @@ func loadManifest(m io.Medium, path string) (*Manifest, error) { content, err := m.Read(path) if err != nil { - if os.IsNotExist(err) { + if core.Is(err, fs.ErrNotExist) { return manifest, nil } return nil, err } - if err := json.Unmarshal([]byte(content), manifest); err != nil { - return nil, err + result := core.JSONUnmarshalString(content, manifest) + if !result.OK { + return nil, result.Value.(error) } manifest.medium = m manifest.path = path @@ -191,9 +196,9 @@ func loadManifest(m io.Medium, path string) (*Manifest, error) { // Save writes the manifest to disk. func (m *Manifest) Save() error { - data, err := json.MarshalIndent(m, "", " ") - if err != nil { - return err + result := core.JSONMarshal(m) + if !result.OK { + return result.Value.(error) } - return m.medium.Write(m.path, string(data)) + return m.medium.Write(m.path, string(result.Value.([]byte))) } diff --git a/devenv/images_test.go b/devenv/images_test.go index 795212d..4f199dc 100644 --- a/devenv/images_test.go +++ b/devenv/images_test.go @@ -2,18 +2,17 @@ package devenv import ( "context" - "os" - "path/filepath" "testing" "time" - "forge.lthn.ai/core/go-container/sources" - "forge.lthn.ai/core/go-io" + "dappco.re/go/core/container/internal/coreutil" + "dappco.re/go/core/container/sources" + "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestImageManager_Good_IsInstalled(t *testing.T) { +func TestImageManager_IsInstalled_Good(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -25,15 +24,15 @@ func TestImageManager_Good_IsInstalled(t *testing.T) { assert.False(t, mgr.IsInstalled()) // Create fake image - imagePath := filepath.Join(tmpDir, ImageName()) - err = os.WriteFile(imagePath, []byte("fake"), 0644) + imagePath := coreutil.JoinPath(tmpDir, ImageName()) + err = io.Local.Write(imagePath, "fake") require.NoError(t, err) // Now installed assert.True(t, mgr.IsInstalled()) } -func TestNewImageManager_Good(t *testing.T) { +func TestImages_NewImageManager_Good(t *testing.T) { t.Run("creates manager with cdn source", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -63,9 +62,9 @@ func TestNewImageManager_Good(t *testing.T) { }) } -func TestManifest_Save(t *testing.T) { +func TestManifest_Save_Good(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "manifest.json") + path := coreutil.JoinPath(tmpDir, "manifest.json") m := &Manifest{ medium: io.Local, @@ -82,8 +81,7 @@ func TestManifest_Save(t *testing.T) { assert.NoError(t, err) // Verify file exists and has content - _, err = os.Stat(path) - assert.NoError(t, err) + assert.True(t, io.Local.IsFile(path)) // Reload m2, err := loadManifest(io.Local, path) @@ -91,11 +89,11 @@ func TestManifest_Save(t *testing.T) { assert.Equal(t, "1.0.0", m2.Images["test.img"].Version) } -func TestLoadManifest_Bad(t *testing.T) { +func TestImages_LoadManifest_Bad(t *testing.T) { t.Run("invalid json", func(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "manifest.json") - err := os.WriteFile(path, []byte("invalid json"), 0644) + path := coreutil.JoinPath(tmpDir, "manifest.json") + err := io.Local.Write(path, "invalid json") require.NoError(t, err) _, err = loadManifest(io.Local, path) @@ -103,7 +101,7 @@ func TestLoadManifest_Bad(t *testing.T) { }) } -func TestCheckUpdate_Bad(t *testing.T) { +func TestImages_CheckUpdate_Bad(t *testing.T) { t.Run("image not installed", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -118,7 +116,7 @@ func TestCheckUpdate_Bad(t *testing.T) { }) } -func TestNewImageManager_Good_AutoSource(t *testing.T) { +func TestNewImageManager_AutoSource_Good(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -131,7 +129,7 @@ func TestNewImageManager_Good_AutoSource(t *testing.T) { assert.Len(t, mgr.sources, 2) // github and cdn } -func TestNewImageManager_Good_UnknownSourceFallsToAuto(t *testing.T) { +func TestNewImageManager_UnknownSourceFallsToAuto_Good(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -144,9 +142,9 @@ func TestNewImageManager_Good_UnknownSourceFallsToAuto(t *testing.T) { assert.Len(t, mgr.sources, 2) // falls to default (auto) which is github + cdn } -func TestLoadManifest_Good_Empty(t *testing.T) { +func TestLoadManifest_Empty_Good(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "nonexistent.json") + path := coreutil.JoinPath(tmpDir, "nonexistent.json") m, err := loadManifest(io.Local, path) assert.NoError(t, err) @@ -156,12 +154,12 @@ func TestLoadManifest_Good_Empty(t *testing.T) { assert.Equal(t, path, m.path) } -func TestLoadManifest_Good_ExistingData(t *testing.T) { +func TestLoadManifest_ExistingData_Good(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "manifest.json") + path := coreutil.JoinPath(tmpDir, "manifest.json") data := `{"images":{"test.img":{"version":"2.0.0","source":"cdn"}}}` - err := os.WriteFile(path, []byte(data), 0644) + err := io.Local.Write(path, data) require.NoError(t, err) m, err := loadManifest(io.Local, path) @@ -171,7 +169,7 @@ func TestLoadManifest_Good_ExistingData(t *testing.T) { assert.Equal(t, "cdn", m.Images["test.img"].Source) } -func TestImageInfo_Struct(t *testing.T) { +func TestImageInfo_Struct_Good(t *testing.T) { info := ImageInfo{ Version: "1.0.0", SHA256: "abc123", @@ -184,9 +182,9 @@ func TestImageInfo_Struct(t *testing.T) { assert.Equal(t, "github", info.Source) } -func TestManifest_Save_Good_CreatesDirs(t *testing.T) { +func TestManifest_SaveCreatesDirs_Good(t *testing.T) { tmpDir := t.TempDir() - nestedPath := filepath.Join(tmpDir, "nested", "dir", "manifest.json") + nestedPath := coreutil.JoinPath(tmpDir, "nested", "dir", "manifest.json") m := &Manifest{ medium: io.Local, @@ -200,13 +198,12 @@ func TestManifest_Save_Good_CreatesDirs(t *testing.T) { assert.NoError(t, err) // Verify file was created - _, err = os.Stat(nestedPath) - assert.NoError(t, err) + assert.True(t, io.Local.IsFile(nestedPath)) } -func TestManifest_Save_Good_Overwrite(t *testing.T) { +func TestManifest_SaveOverwrite_Good(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "manifest.json") + path := coreutil.JoinPath(tmpDir, "manifest.json") // First save m1 := &Manifest{ @@ -236,7 +233,7 @@ func TestManifest_Save_Good_Overwrite(t *testing.T) { assert.False(t, exists) } -func TestImageManager_Install_Bad_NoSourceAvailable(t *testing.T) { +func TestImageManager_InstallNoSourceAvailable_Bad(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -244,7 +241,7 @@ func TestImageManager_Install_Bad_NoSourceAvailable(t *testing.T) { mgr := &ImageManager{ medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")}, sources: nil, // no sources } @@ -253,9 +250,9 @@ func TestImageManager_Install_Bad_NoSourceAvailable(t *testing.T) { assert.Contains(t, err.Error(), "no image source available") } -func TestNewImageManager_Good_CreatesDir(t *testing.T) { +func TestNewImageManager_CreatesDir_Good(t *testing.T) { tmpDir := t.TempDir() - imagesDir := filepath.Join(tmpDir, "images") + imagesDir := coreutil.JoinPath(tmpDir, "images") t.Setenv("CORE_IMAGES_DIR", imagesDir) cfg := DefaultConfig() @@ -264,7 +261,7 @@ func TestNewImageManager_Good_CreatesDir(t *testing.T) { assert.NotNil(t, mgr) // Verify directory was created - info, err := os.Stat(imagesDir) + info, err := io.Local.Stat(imagesDir) assert.NoError(t, err) assert.True(t, info.IsDir()) } @@ -288,11 +285,11 @@ func (m *mockImageSource) Download(ctx context.Context, medium io.Medium, dest s return m.downloadErr } // Create a fake image file - imagePath := filepath.Join(dest, ImageName()) - return os.WriteFile(imagePath, []byte("mock image content"), 0644) + imagePath := coreutil.JoinPath(dest, ImageName()) + return medium.Write(imagePath, "mock image content") } -func TestImageManager_Install_Good_WithMockSource(t *testing.T) { +func TestImageManager_InstallWithMockSource_Good(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -305,7 +302,7 @@ func TestImageManager_Install_Good_WithMockSource(t *testing.T) { mgr := &ImageManager{ medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")}, sources: []sources.ImageSource{mock}, } @@ -320,7 +317,7 @@ func TestImageManager_Install_Good_WithMockSource(t *testing.T) { assert.Equal(t, "mock", info.Source) } -func TestImageManager_Install_Bad_DownloadError(t *testing.T) { +func TestImageManager_InstallDownloadError_Bad(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -334,7 +331,7 @@ func TestImageManager_Install_Bad_DownloadError(t *testing.T) { mgr := &ImageManager{ medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")}, sources: []sources.ImageSource{mock}, } @@ -342,7 +339,7 @@ func TestImageManager_Install_Bad_DownloadError(t *testing.T) { assert.Error(t, err) } -func TestImageManager_Install_Bad_VersionError(t *testing.T) { +func TestImageManager_InstallVersionError_Bad(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -355,7 +352,7 @@ func TestImageManager_Install_Bad_VersionError(t *testing.T) { mgr := &ImageManager{ medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")}, sources: []sources.ImageSource{mock}, } @@ -364,7 +361,7 @@ func TestImageManager_Install_Bad_VersionError(t *testing.T) { assert.Contains(t, err.Error(), "failed to get latest version") } -func TestImageManager_Install_Good_SkipsUnavailableSource(t *testing.T) { +func TestImageManager_InstallSkipsUnavailableSource_Good(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -381,7 +378,7 @@ func TestImageManager_Install_Good_SkipsUnavailableSource(t *testing.T) { mgr := &ImageManager{ medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")}, sources: []sources.ImageSource{unavailableMock, availableMock}, } @@ -393,7 +390,7 @@ func TestImageManager_Install_Good_SkipsUnavailableSource(t *testing.T) { assert.Equal(t, "available", info.Source) } -func TestImageManager_CheckUpdate_Good_WithMockSource(t *testing.T) { +func TestImageManager_CheckUpdateWithMockSource_Good(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -411,7 +408,7 @@ func TestImageManager_CheckUpdate_Good_WithMockSource(t *testing.T) { Images: map[string]ImageInfo{ ImageName(): {Version: "v1.0.0", Source: "mock"}, }, - path: filepath.Join(tmpDir, "manifest.json"), + path: coreutil.JoinPath(tmpDir, "manifest.json"), }, sources: []sources.ImageSource{mock}, } @@ -423,7 +420,7 @@ func TestImageManager_CheckUpdate_Good_WithMockSource(t *testing.T) { assert.True(t, hasUpdate) } -func TestImageManager_CheckUpdate_Good_NoUpdate(t *testing.T) { +func TestImageManager_CheckUpdateNoUpdate_Good(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -441,7 +438,7 @@ func TestImageManager_CheckUpdate_Good_NoUpdate(t *testing.T) { Images: map[string]ImageInfo{ ImageName(): {Version: "v1.0.0", Source: "mock"}, }, - path: filepath.Join(tmpDir, "manifest.json"), + path: coreutil.JoinPath(tmpDir, "manifest.json"), }, sources: []sources.ImageSource{mock}, } @@ -453,7 +450,7 @@ func TestImageManager_CheckUpdate_Good_NoUpdate(t *testing.T) { assert.False(t, hasUpdate) } -func TestImageManager_CheckUpdate_Bad_NoSource(t *testing.T) { +func TestImageManager_CheckUpdateNoSource_Bad(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -470,7 +467,7 @@ func TestImageManager_CheckUpdate_Bad_NoSource(t *testing.T) { Images: map[string]ImageInfo{ ImageName(): {Version: "v1.0.0", Source: "mock"}, }, - path: filepath.Join(tmpDir, "manifest.json"), + path: coreutil.JoinPath(tmpDir, "manifest.json"), }, sources: []sources.ImageSource{unavailableMock}, } @@ -480,7 +477,7 @@ func TestImageManager_CheckUpdate_Bad_NoSource(t *testing.T) { assert.Contains(t, err.Error(), "no image source available") } -func TestImageManager_CheckUpdate_Bad_VersionError(t *testing.T) { +func TestImageManager_CheckUpdateVersionError_Bad(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -498,7 +495,7 @@ func TestImageManager_CheckUpdate_Bad_VersionError(t *testing.T) { Images: map[string]ImageInfo{ ImageName(): {Version: "v1.0.0", Source: "mock"}, }, - path: filepath.Join(tmpDir, "manifest.json"), + path: coreutil.JoinPath(tmpDir, "manifest.json"), }, sources: []sources.ImageSource{mock}, } @@ -508,14 +505,14 @@ func TestImageManager_CheckUpdate_Bad_VersionError(t *testing.T) { assert.Equal(t, "v1.0.0", current) // Current should still be returned } -func TestImageManager_Install_Bad_EmptySources(t *testing.T) { +func TestImageManager_InstallEmptySources_Bad(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) mgr := &ImageManager{ medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")}, sources: []sources.ImageSource{}, // Empty slice, not nil } @@ -524,7 +521,7 @@ func TestImageManager_Install_Bad_EmptySources(t *testing.T) { assert.Contains(t, err.Error(), "no image source available") } -func TestImageManager_Install_Bad_AllUnavailable(t *testing.T) { +func TestImageManager_InstallAllUnavailable_Bad(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -534,7 +531,7 @@ func TestImageManager_Install_Bad_AllUnavailable(t *testing.T) { mgr := &ImageManager{ medium: io.Local, config: DefaultConfig(), - manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, + manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: coreutil.JoinPath(tmpDir, "manifest.json")}, sources: []sources.ImageSource{mock1, mock2}, } @@ -543,7 +540,7 @@ func TestImageManager_Install_Bad_AllUnavailable(t *testing.T) { assert.Contains(t, err.Error(), "no image source available") } -func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(t *testing.T) { +func TestImageManager_CheckUpdateFirstSourceUnavailable_Good(t *testing.T) { tmpDir := t.TempDir() t.Setenv("CORE_IMAGES_DIR", tmpDir) @@ -558,7 +555,7 @@ func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(t *testing.T) { Images: map[string]ImageInfo{ ImageName(): {Version: "v1.0.0", Source: "available"}, }, - path: filepath.Join(tmpDir, "manifest.json"), + path: coreutil.JoinPath(tmpDir, "manifest.json"), }, sources: []sources.ImageSource{unavailable, available}, } @@ -570,7 +567,7 @@ func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(t *testing.T) { assert.True(t, hasUpdate) } -func TestManifest_Struct(t *testing.T) { +func TestManifest_Struct_Good(t *testing.T) { m := &Manifest{ Images: map[string]ImageInfo{ "test.img": {Version: "1.0.0"}, diff --git a/devenv/serve.go b/devenv/serve.go index 022cf52..bcf9ab1 100644 --- a/devenv/serve.go +++ b/devenv/serve.go @@ -2,13 +2,13 @@ package devenv import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/coreutil" + "dappco.re/go/core/container/internal/proc" ) // ServeOptions configures the dev server. @@ -33,7 +33,7 @@ func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions servePath := projectDir if opts.Path != "" { - servePath = filepath.Join(projectDir, opts.Path) + servePath = coreutil.JoinPath(projectDir, opts.Path) } // 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 serveCmd := DetectServeCommand(d.medium, servePath) - fmt.Printf("Starting server: %s\n", serveCmd) - fmt.Printf("Listening on http://localhost:%d\n", opts.Port) + core.Print(nil, "Starting server: %s", serveCmd) + core.Print(nil, "Listening on http://localhost:%d", opts.Port) // Run serve command via SSH return d.sshShell(ctx, []string{"cd", "/app", "&&", serveCmd}) @@ -52,26 +52,27 @@ func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions // mountProject mounts a directory into the VM via SSHFS. func (d *DevOps) mountProject(ctx context.Context, path string) error { - absPath, err := filepath.Abs(path) - if err != nil { - return err - } + absPath := coreutil.AbsPath(path) // Use reverse SSHFS mount // The VM connects back to host to mount the directory - cmd := exec.CommandContext(ctx, "ssh", + cmd := proc.NewCommandContext(ctx, "ssh", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "LogLevel=ERROR", "-R", "10000:localhost:22", // Reverse tunnel for SSHFS - "-p", fmt.Sprintf("%d", DefaultSSHPort), + "-p", core.Sprintf("%d", DefaultSSHPort), "root@localhost", - fmt.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", os.Getenv("USER"), absPath), + core.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", core.Env("USER"), absPath), ) return cmd.Run() } // DetectServeCommand auto-detects the serve command for a project. +// +// Usage: +// +// cmd := DetectServeCommand(io.Local, ".") func DetectServeCommand(m io.Medium, projectDir string) string { // Laravel/Octane if hasFile(m, projectDir, "artisan") { diff --git a/devenv/serve_test.go b/devenv/serve_test.go index 1c2f61e..a0db94c 100644 --- a/devenv/serve_test.go +++ b/devenv/serve_test.go @@ -1,66 +1,65 @@ package devenv import ( - "os" - "path/filepath" "testing" - "forge.lthn.ai/core/go-io" + "dappco.re/go/core/container/internal/coreutil" + "dappco.re/go/core/io" "github.com/stretchr/testify/assert" ) -func TestDetectServeCommand_Good_Laravel(t *testing.T) { +func TestDetectServeCommand_Laravel_Good(t *testing.T) { tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "artisan"), "#!/usr/bin/env php") assert.NoError(t, err) cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) } -func TestDetectServeCommand_Good_NodeDev(t *testing.T) { +func TestDetectServeCommand_NodeDev_Good(t *testing.T) { tmpDir := t.TempDir() packageJSON := `{"scripts":{"dev":"vite","start":"node index.js"}}` - err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), packageJSON) assert.NoError(t, err) cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "npm run dev -- --host 0.0.0.0", cmd) } -func TestDetectServeCommand_Good_NodeStart(t *testing.T) { +func TestDetectServeCommand_NodeStart_Good(t *testing.T) { tmpDir := t.TempDir() packageJSON := `{"scripts":{"start":"node server.js"}}` - err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), packageJSON) assert.NoError(t, err) cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "npm start", cmd) } -func TestDetectServeCommand_Good_PHP(t *testing.T) { +func TestDetectServeCommand_PHP_Good(t *testing.T) { tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"require":{}}`) assert.NoError(t, err) cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "frankenphp php-server -l :8000", cmd) } -func TestDetectServeCommand_Good_GoMain(t *testing.T) { +func TestDetectServeCommand_GoMain_Good(t *testing.T) { tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example") assert.NoError(t, err) - err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644) + err = io.Local.Write(coreutil.JoinPath(tmpDir, "main.go"), "package main") assert.NoError(t, err) cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "go run .", cmd) } -func TestDetectServeCommand_Good_GoWithoutMain(t *testing.T) { +func TestDetectServeCommand_GoWithoutMain_Good(t *testing.T) { tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example") assert.NoError(t, err) // No main.go, so falls through to fallback @@ -68,41 +67,41 @@ func TestDetectServeCommand_Good_GoWithoutMain(t *testing.T) { assert.Equal(t, "python3 -m http.server 8000", cmd) } -func TestDetectServeCommand_Good_Django(t *testing.T) { +func TestDetectServeCommand_Django_Good(t *testing.T) { tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "manage.py"), []byte("#!/usr/bin/env python"), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "manage.py"), "#!/usr/bin/env python") assert.NoError(t, err) cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "python manage.py runserver 0.0.0.0:8000", cmd) } -func TestDetectServeCommand_Good_Fallback(t *testing.T) { +func TestDetectServeCommand_Fallback_Good(t *testing.T) { tmpDir := t.TempDir() cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "python3 -m http.server 8000", cmd) } -func TestDetectServeCommand_Good_Priority(t *testing.T) { +func TestDetectServeCommand_Priority_Good(t *testing.T) { // Laravel (artisan) should take priority over PHP (composer.json) tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "artisan"), "#!/usr/bin/env php") assert.NoError(t, err) - err = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) + err = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"require":{}}`) assert.NoError(t, err) cmd := DetectServeCommand(io.Local, tmpDir) assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) } -func TestServeOptions_Default(t *testing.T) { +func TestServeOptions_Default_Good(t *testing.T) { opts := ServeOptions{} assert.Equal(t, 0, opts.Port) assert.Equal(t, "", opts.Path) } -func TestServeOptions_Custom(t *testing.T) { +func TestServeOptions_Custom_Good(t *testing.T) { opts := ServeOptions{ Port: 3000, Path: "public", @@ -111,25 +110,25 @@ func TestServeOptions_Custom(t *testing.T) { assert.Equal(t, "public", opts.Path) } -func TestHasFile_Good(t *testing.T) { +func TestServe_HasFile_Good(t *testing.T) { tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - err := os.WriteFile(testFile, []byte("content"), 0644) + testFile := coreutil.JoinPath(tmpDir, "test.txt") + err := io.Local.Write(testFile, "content") assert.NoError(t, err) assert.True(t, hasFile(io.Local, tmpDir, "test.txt")) } -func TestHasFile_Bad(t *testing.T) { +func TestServe_HasFile_Bad(t *testing.T) { tmpDir := t.TempDir() assert.False(t, hasFile(io.Local, tmpDir, "nonexistent.txt")) } -func TestHasFile_Bad_Directory(t *testing.T) { +func TestHasFile_Directory_Bad(t *testing.T) { tmpDir := t.TempDir() - subDir := filepath.Join(tmpDir, "subdir") - err := os.Mkdir(subDir, 0755) + subDir := coreutil.JoinPath(tmpDir, "subdir") + err := io.Local.EnsureDir(subDir) assert.NoError(t, err) // hasFile correctly returns false for directories (only true for regular files) diff --git a/devenv/shell.go b/devenv/shell.go index 1aac88f..52b0148 100644 --- a/devenv/shell.go +++ b/devenv/shell.go @@ -2,11 +2,11 @@ package devenv import ( "context" - "fmt" - "os" - "os/exec" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/proc" ) // ShellOptions configures the shell connection. @@ -39,7 +39,7 @@ func (d *DevOps) sshShell(ctx context.Context, command []string) error { "-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "LogLevel=ERROR", "-A", // Agent forwarding - "-p", fmt.Sprintf("%d", DefaultSSHPort), + "-p", core.Sprintf("%d", DefaultSSHPort), "root@localhost", } @@ -47,10 +47,10 @@ func (d *DevOps) sshShell(ctx context.Context, command []string) error { args = append(args, command...) } - cmd := exec.CommandContext(ctx, "ssh", args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd := proc.NewCommandContext(ctx, "ssh", args...) + cmd.Stdin = proc.Stdin + cmd.Stdout = proc.Stdout + cmd.Stderr = proc.Stderr return cmd.Run() } @@ -67,10 +67,10 @@ func (d *DevOps) serialConsole(ctx context.Context) error { } // Use socat to connect to the console socket - socketPath := fmt.Sprintf("/tmp/core-%s-console.sock", c.ID) - cmd := exec.CommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + socketPath := core.Sprintf("/tmp/core-%s-console.sock", c.ID) + cmd := proc.NewCommandContext(ctx, "socat", "-,raw,echo=0", "unix-connect:"+socketPath) + cmd.Stdin = proc.Stdin + cmd.Stdout = proc.Stdout + cmd.Stderr = proc.Stderr return cmd.Run() } diff --git a/devenv/shell_test.go b/devenv/shell_test.go index b9d57b7..f8f4daf 100644 --- a/devenv/shell_test.go +++ b/devenv/shell_test.go @@ -6,13 +6,13 @@ import ( "github.com/stretchr/testify/assert" ) -func TestShellOptions_Default(t *testing.T) { +func TestShellOptions_Default_Good(t *testing.T) { opts := ShellOptions{} assert.False(t, opts.Console) assert.Nil(t, opts.Command) } -func TestShellOptions_Console(t *testing.T) { +func TestShellOptions_Console_Good(t *testing.T) { opts := ShellOptions{ Console: true, } @@ -20,7 +20,7 @@ func TestShellOptions_Console(t *testing.T) { assert.Nil(t, opts.Command) } -func TestShellOptions_Command(t *testing.T) { +func TestShellOptions_Command_Good(t *testing.T) { opts := ShellOptions{ Command: []string{"ls", "-la"}, } @@ -28,7 +28,7 @@ func TestShellOptions_Command(t *testing.T) { assert.Equal(t, []string{"ls", "-la"}, opts.Command) } -func TestShellOptions_ConsoleWithCommand(t *testing.T) { +func TestShellOptions_ConsoleWithCommand_Good(t *testing.T) { opts := ShellOptions{ Console: true, Command: []string{"echo", "hello"}, @@ -37,7 +37,7 @@ func TestShellOptions_ConsoleWithCommand(t *testing.T) { assert.Equal(t, []string{"echo", "hello"}, opts.Command) } -func TestShellOptions_EmptyCommand(t *testing.T) { +func TestShellOptions_EmptyCommand_Good(t *testing.T) { opts := ShellOptions{ Command: []string{}, } diff --git a/devenv/ssh_utils.go b/devenv/ssh_utils.go index f65d5c8..03074d4 100644 --- a/devenv/ssh_utils.go +++ b/devenv/ssh_utils.go @@ -2,38 +2,37 @@ package devenv import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - coreio "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/coreutil" + "dappco.re/go/core/container/internal/proc" ) // 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. func ensureHostKey(ctx context.Context, port int) error { // Skip if requested (used in tests) - if os.Getenv("CORE_SKIP_SSH_SCAN") == "true" { + if core.Env("CORE_SKIP_SSH_SCAN") == "true" { return nil } - home, err := os.UserHomeDir() - if err != nil { - return coreerr.E("ensureHostKey", "get home dir", err) + home := coreutil.HomeDir() + if home == "" { + return coreerr.E("ensureHostKey", "get home dir", nil) } - knownHostsPath := filepath.Join(home, ".core", "known_hosts") + knownHostsPath := coreutil.JoinPath(home, ".core", "known_hosts") // Ensure directory exists - if err := coreio.Local.EnsureDir(filepath.Dir(knownHostsPath)); err != nil { + if err := coreio.Local.EnsureDir(core.PathDir(knownHostsPath)); err != nil { return coreerr.E("ensureHostKey", "create known_hosts dir", err) } // Get host key using ssh-keyscan - cmd := exec.CommandContext(ctx, "ssh-keyscan", "-p", fmt.Sprintf("%d", port), "localhost") + cmd := proc.NewCommandContext(ctx, "ssh-keyscan", "-p", core.Sprintf("%d", port), "localhost") out, err := cmd.Output() if err != nil { return coreerr.E("ensureHostKey", "ssh-keyscan failed", err) @@ -46,21 +45,27 @@ func ensureHostKey(ctx context.Context, port int) error { // Read existing known_hosts to avoid duplicates 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 - f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + f, err := coreio.Local.Append(knownHostsPath) if err != nil { return coreerr.E("ensureHostKey", "open known_hosts", err) } defer f.Close() - lines := strings.Split(string(out), "\n") + lines := core.Split(string(out), "\n") for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { + line = core.Trim(line) + if line == "" || core.HasPrefix(line, "#") { continue } - if !strings.Contains(existingStr, line) { - if _, err := f.WriteString(line + "\n"); err != nil { + if !core.Contains(existingStr, line) { + if _, err := f.Write([]byte(core.Concat(line, "\n"))); err != nil { return coreerr.E("ensureHostKey", "write known_hosts", err) } } diff --git a/devenv/test.go b/devenv/test.go index 6de6085..6c91114 100644 --- a/devenv/test.go +++ b/devenv/test.go @@ -2,13 +2,13 @@ package devenv import ( "context" - "encoding/json" - "path/filepath" - "strings" - "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" "gopkg.in/yaml.v3" + + "dappco.re/go/core/container/internal/coreutil" ) // 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 if len(opts.Command) > 0 { - cmd = strings.Join(opts.Command, " ") + cmd = core.Join(" ", opts.Command...) } else if opts.Name != "" { cfg, err := LoadTestConfig(d.medium, projectDir) if err != nil { @@ -72,6 +72,10 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions) } // DetectTestCommand auto-detects the test command for a project. +// +// Usage: +// +// cmd := DetectTestCommand(io.Local, ".") func DetectTestCommand(m io.Medium, projectDir string) string { // 1. Check .core/test.yaml cfg, err := LoadTestConfig(m, projectDir) @@ -112,12 +116,12 @@ func DetectTestCommand(m io.Medium, projectDir string) string { } // LoadTestConfig loads .core/test.yaml. +// +// Usage: +// +// cfg, err := LoadTestConfig(io.Local, ".") func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) { - path := filepath.Join(projectDir, ".core", "test.yaml") - absPath, err := filepath.Abs(path) - if err != nil { - return nil, err - } + absPath := coreutil.AbsPath(coreutil.JoinPath(projectDir, ".core", "test.yaml")) content, err := m.Read(absPath) if err != nil { @@ -133,20 +137,12 @@ func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) { } func hasFile(m io.Medium, dir, name string) bool { - path := filepath.Join(dir, name) - absPath, err := filepath.Abs(path) - if err != nil { - return false - } + absPath := coreutil.AbsPath(coreutil.JoinPath(dir, name)) return m.IsFile(absPath) } func hasPackageScript(m io.Medium, projectDir, script string) bool { - path := filepath.Join(projectDir, "package.json") - absPath, err := filepath.Abs(path) - if err != nil { - return false - } + absPath := coreutil.AbsPath(coreutil.JoinPath(projectDir, "package.json")) content, err := m.Read(absPath) if err != nil { @@ -156,7 +152,8 @@ func hasPackageScript(m io.Medium, projectDir, script string) bool { var pkg struct { Scripts map[string]string `json:"scripts"` } - if err := json.Unmarshal([]byte(content), &pkg); err != nil { + result := core.JSONUnmarshalString(content, &pkg) + if !result.OK { return false } @@ -165,11 +162,7 @@ func hasPackageScript(m io.Medium, projectDir, script string) bool { } func hasComposerScript(m io.Medium, projectDir, script string) bool { - path := filepath.Join(projectDir, "composer.json") - absPath, err := filepath.Abs(path) - if err != nil { - return false - } + absPath := coreutil.AbsPath(coreutil.JoinPath(projectDir, "composer.json")) content, err := m.Read(absPath) if err != nil { @@ -179,7 +172,8 @@ func hasComposerScript(m io.Medium, projectDir, script string) bool { var pkg struct { Scripts map[string]any `json:"scripts"` } - if err := json.Unmarshal([]byte(content), &pkg); err != nil { + result := core.JSONUnmarshalString(content, &pkg) + if !result.OK { return false } diff --git a/devenv/test_test.go b/devenv/test_test.go index fd1e23a..ddfb99f 100644 --- a/devenv/test_test.go +++ b/devenv/test_test.go @@ -1,16 +1,15 @@ package devenv import ( - "os" - "path/filepath" "testing" - "forge.lthn.ai/core/go-io" + "dappco.re/go/core/container/internal/coreutil" + "dappco.re/go/core/io" ) -func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) { +func TestDetectTestCommand_ComposerJSON_Good(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"scripts":{"test":"pest"}}`) cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "composer test" { @@ -18,9 +17,9 @@ func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) { } } -func TestDetectTestCommand_Good_PackageJSON(t *testing.T) { +func TestDetectTestCommand_PackageJSON_Good(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"scripts":{"test":"vitest"}}`) cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "npm test" { @@ -28,9 +27,9 @@ func TestDetectTestCommand_Good_PackageJSON(t *testing.T) { } } -func TestDetectTestCommand_Good_GoMod(t *testing.T) { +func TestDetectTestCommand_GoMod_Good(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example") cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "go test ./..." { @@ -38,11 +37,11 @@ func TestDetectTestCommand_Good_GoMod(t *testing.T) { } } -func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) { +func TestDetectTestCommand_CoreTestYaml_Good(t *testing.T) { tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) - _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644) + coreDir := coreutil.JoinPath(tmpDir, ".core") + _ = io.Local.EnsureDir(coreDir) + _ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "command: custom-test") cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "custom-test" { @@ -50,9 +49,9 @@ func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) { } } -func TestDetectTestCommand_Good_Pytest(t *testing.T) { +func TestDetectTestCommand_Pytest_Good(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "pytest.ini"), []byte("[pytest]"), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "pytest.ini"), "[pytest]") cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "pytest" { @@ -60,9 +59,9 @@ func TestDetectTestCommand_Good_Pytest(t *testing.T) { } } -func TestDetectTestCommand_Good_Taskfile(t *testing.T) { +func TestDetectTestCommand_Taskfile_Good(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), []byte("version: '3'"), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "Taskfile.yaml"), "version: '3'") cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "task test" { @@ -70,7 +69,7 @@ func TestDetectTestCommand_Good_Taskfile(t *testing.T) { } } -func TestDetectTestCommand_Bad_NoFiles(t *testing.T) { +func TestDetectTestCommand_NoFiles_Bad(t *testing.T) { tmpDir := t.TempDir() cmd := DetectTestCommand(io.Local, tmpDir) @@ -79,13 +78,13 @@ func TestDetectTestCommand_Bad_NoFiles(t *testing.T) { } } -func TestDetectTestCommand_Good_Priority(t *testing.T) { +func TestDetectTestCommand_Priority_Good(t *testing.T) { // .core/test.yaml should take priority over other detection methods tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) - _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: my-custom-test"), 0644) - _ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) + coreDir := coreutil.JoinPath(tmpDir, ".core") + _ = io.Local.EnsureDir(coreDir) + _ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "command: my-custom-test") + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "go.mod"), "module example") cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "my-custom-test" { @@ -93,10 +92,10 @@ func TestDetectTestCommand_Good_Priority(t *testing.T) { } } -func TestLoadTestConfig_Good(t *testing.T) { +func TestTest_LoadTestConfig_Good(t *testing.T) { tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) + coreDir := coreutil.JoinPath(tmpDir, ".core") + _ = io.Local.EnsureDir(coreDir) configYAML := `version: 1 command: default-test @@ -108,7 +107,7 @@ commands: env: CI: "true" ` - _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte(configYAML), 0644) + _ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), configYAML) cfg, err := LoadTestConfig(io.Local, tmpDir) if err != nil { @@ -132,7 +131,7 @@ env: } } -func TestLoadTestConfig_Bad_NotFound(t *testing.T) { +func TestLoadTestConfig_NotFound_Bad(t *testing.T) { tmpDir := t.TempDir() _, err := LoadTestConfig(io.Local, tmpDir) @@ -141,9 +140,9 @@ func TestLoadTestConfig_Bad_NotFound(t *testing.T) { } } -func TestHasPackageScript_Good(t *testing.T) { +func TestTest_HasPackageScript_Good(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"jest","build":"webpack"}}`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"scripts":{"test":"jest","build":"webpack"}}`) if !hasPackageScript(io.Local, tmpDir, "test") { t.Error("expected to find 'test' script") @@ -153,34 +152,34 @@ func TestHasPackageScript_Good(t *testing.T) { } } -func TestHasPackageScript_Bad_MissingScript(t *testing.T) { +func TestHasPackageScript_MissingScript_Bad(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"build":"webpack"}}`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"scripts":{"build":"webpack"}}`) if hasPackageScript(io.Local, tmpDir, "test") { t.Error("expected not to find 'test' script") } } -func TestHasComposerScript_Good(t *testing.T) { +func TestTest_HasComposerScript_Good(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`) if !hasComposerScript(io.Local, tmpDir, "test") { t.Error("expected to find 'test' script") } } -func TestHasComposerScript_Bad_MissingScript(t *testing.T) { +func TestHasComposerScript_MissingScript_Bad(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"build":"@php build.php"}}`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"scripts":{"build":"@php build.php"}}`) if hasComposerScript(io.Local, tmpDir, "test") { t.Error("expected not to find 'test' script") } } -func TestTestConfig_Struct(t *testing.T) { +func TestTestConfig_Struct_Good(t *testing.T) { cfg := &TestConfig{ Version: 2, Command: "my-test", @@ -201,7 +200,7 @@ func TestTestConfig_Struct(t *testing.T) { } } -func TestTestCommand_Struct(t *testing.T) { +func TestTestCommand_Struct_Good(t *testing.T) { cmd := TestCommand{ Name: "integration", Run: "go test -tags=integration ./...", @@ -214,7 +213,7 @@ func TestTestCommand_Struct(t *testing.T) { } } -func TestTestOptions_Struct(t *testing.T) { +func TestTestOptions_Struct_Good(t *testing.T) { opts := TestOptions{ Name: "unit", Command: []string{"go", "test", "-v"}, @@ -227,9 +226,9 @@ func TestTestOptions_Struct(t *testing.T) { } } -func TestDetectTestCommand_Good_TaskfileYml(t *testing.T) { +func TestDetectTestCommand_TaskfileYml_Good(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yml"), []byte("version: '3'"), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "Taskfile.yml"), "version: '3'") cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "task test" { @@ -237,9 +236,9 @@ func TestDetectTestCommand_Good_TaskfileYml(t *testing.T) { } } -func TestDetectTestCommand_Good_Pyproject(t *testing.T) { +func TestDetectTestCommand_Pyproject_Good(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "pyproject.toml"), []byte("[tool.pytest]"), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "pyproject.toml"), "[tool.pytest]") cmd := DetectTestCommand(io.Local, tmpDir) if cmd != "pytest" { @@ -247,7 +246,7 @@ func TestDetectTestCommand_Good_Pyproject(t *testing.T) { } } -func TestHasPackageScript_Bad_NoFile(t *testing.T) { +func TestHasPackageScript_NoFile_Bad(t *testing.T) { tmpDir := t.TempDir() if hasPackageScript(io.Local, tmpDir, "test") { @@ -255,25 +254,25 @@ func TestHasPackageScript_Bad_NoFile(t *testing.T) { } } -func TestHasPackageScript_Bad_InvalidJSON(t *testing.T) { +func TestHasPackageScript_InvalidJSON_Bad(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`invalid json`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `invalid json`) if hasPackageScript(io.Local, tmpDir, "test") { t.Error("expected false for invalid JSON") } } -func TestHasPackageScript_Bad_NoScripts(t *testing.T) { +func TestHasPackageScript_NoScripts_Bad(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"name":"test"}`) if hasPackageScript(io.Local, tmpDir, "test") { t.Error("expected false for missing scripts section") } } -func TestHasComposerScript_Bad_NoFile(t *testing.T) { +func TestHasComposerScript_NoFile_Bad(t *testing.T) { tmpDir := t.TempDir() if hasComposerScript(io.Local, tmpDir, "test") { @@ -281,29 +280,29 @@ func TestHasComposerScript_Bad_NoFile(t *testing.T) { } } -func TestHasComposerScript_Bad_InvalidJSON(t *testing.T) { +func TestHasComposerScript_InvalidJSON_Bad(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`invalid json`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `invalid json`) if hasComposerScript(io.Local, tmpDir, "test") { t.Error("expected false for invalid JSON") } } -func TestHasComposerScript_Bad_NoScripts(t *testing.T) { +func TestHasComposerScript_NoScripts_Bad(t *testing.T) { tmpDir := t.TempDir() - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"name":"test/pkg"}`) if hasComposerScript(io.Local, tmpDir, "test") { t.Error("expected false for missing scripts section") } } -func TestLoadTestConfig_Bad_InvalidYAML(t *testing.T) { +func TestLoadTestConfig_InvalidYAML_Bad(t *testing.T) { tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) - _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("invalid: yaml: :"), 0644) + coreDir := coreutil.JoinPath(tmpDir, ".core") + _ = io.Local.EnsureDir(coreDir) + _ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "invalid: yaml: :") _, err := LoadTestConfig(io.Local, tmpDir) if err == nil { @@ -311,11 +310,11 @@ func TestLoadTestConfig_Bad_InvalidYAML(t *testing.T) { } } -func TestLoadTestConfig_Good_MinimalConfig(t *testing.T) { +func TestLoadTestConfig_MinimalConfig_Good(t *testing.T) { tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) - _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("version: 1"), 0644) + coreDir := coreutil.JoinPath(tmpDir, ".core") + _ = io.Local.EnsureDir(coreDir) + _ = io.Local.Write(coreutil.JoinPath(coreDir, "test.yaml"), "version: 1") cfg, err := LoadTestConfig(io.Local, tmpDir) if err != nil { @@ -329,10 +328,10 @@ func TestLoadTestConfig_Good_MinimalConfig(t *testing.T) { } } -func TestDetectTestCommand_Good_ComposerWithoutScript(t *testing.T) { +func TestDetectTestCommand_ComposerWithoutScript_Good(t *testing.T) { tmpDir := t.TempDir() // composer.json without test script should not return composer test - _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "composer.json"), `{"name":"test/pkg"}`) cmd := DetectTestCommand(io.Local, tmpDir) // Falls through to empty (no match) @@ -341,10 +340,10 @@ func TestDetectTestCommand_Good_ComposerWithoutScript(t *testing.T) { } } -func TestDetectTestCommand_Good_PackageJSONWithoutScript(t *testing.T) { +func TestDetectTestCommand_PackageJSONWithoutScript_Good(t *testing.T) { tmpDir := t.TempDir() // package.json without test or dev script - _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) + _ = io.Local.Write(coreutil.JoinPath(tmpDir, "package.json"), `{"name":"test"}`) cmd := DetectTestCommand(io.Local, tmpDir) // Falls through to empty diff --git a/docs/development.md b/docs/development.md index 65872e8..7defc08 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,7 +8,7 @@ description: How to build, test, and contribute to go-container. ## Prerequisites - **Go 1.26+** -- The module uses Go 1.26 features. -- **Go workspace** -- This module is part of a Go workspace at `~/Code/go.work`. Local development of sibling modules (go-io, config, go-i18n, cli) requires the workspace file. +- **Go workspace** -- This module is part of a Go workspace at `~/Code/go.work`. Local development of sibling modules (core/io, config, core/i18n, cli) requires the workspace file. Optional (for actually running VMs): @@ -40,7 +40,7 @@ Tests use `testify` for assertions. Most tests are self-contained and do not req ## Test naming convention -Tests follow a `_Good`, `_Bad`, `_Ugly` suffix pattern: +Tests follow the `TestSubject_Function_{Good,Bad,Ugly}` pattern: | Suffix | Meaning | |--------|---------| @@ -51,9 +51,9 @@ Tests follow a `_Good`, `_Bad`, `_Ugly` suffix pattern: Examples from the codebase: ```go -func TestNewState_Good(t *testing.T) { /* creates state successfully */ } -func TestLoadState_Bad_InvalidJSON(t *testing.T) { /* handles corrupt state file */ } -func TestGetHypervisor_Bad_Unknown(t *testing.T) { /* rejects unknown hypervisor name */ } +func TestState_NewState_Good(t *testing.T) { /* creates state successfully */ } +func TestLoadState_InvalidJSON_Bad(t *testing.T) { /* handles corrupt state file */ } +func TestGetHypervisor_Unknown_Bad(t *testing.T) { /* rejects unknown hypervisor name */ } ``` @@ -108,8 +108,8 @@ go-container/ - **UK English** in all strings, comments, and documentation (colour, organisation, honour). - **Strict typing** -- All function parameters and return values are typed. No `interface{}` without justification. -- **Error wrapping** -- Use `fmt.Errorf("context: %w", err)` for all error returns. -- **`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. +- **Error wrapping** -- Use `core.E("Op", "message", err)` rather than `fmt.Errorf`. +- **`io.Medium` abstraction** -- File system operations go through `io.Medium` (from `core/io`) rather than directly calling `os` functions. This enables testing with mock file systems. The `io.Local` singleton is used for real file system access. - **Compile-time interface checks** -- Use `var _ Interface = (*Impl)(nil)` to verify implementations at compile time (see `sources/cdn.go` and `sources/github.go`). - **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) Available() bool { /* check if binary exists */ } -func (h *MyHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { - // Build and return exec.Cmd +func (h *MyHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) { + // Build and return the command. } ``` 2. Register it in `DetectHypervisor()` and `GetHypervisor()` in `hypervisor.go`. -3. Add tests following the `_Good`/`_Bad` naming convention. +3. Add tests following the `TestSubject_Function_{Good,Bad,Ugly}` naming convention. ## Adding a new image source diff --git a/docs/index.md b/docs/index.md index eda6006..fc2ef65 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ description: Container runtime, LinuxKit image builder, and portable development # go-container -`forge.lthn.ai/core/go-container` provides a container runtime built on LinuxKit and lightweight hypervisors. It manages the full lifecycle of LinuxKit virtual machines -- from building images with embedded templates, to running them via QEMU or Hyperkit, to offering a portable development environment with shell access, project mounting, test execution, and Claude AI integration. +`dappco.re/go/core/container` provides a container runtime built on LinuxKit and lightweight hypervisors. It manages the full lifecycle of LinuxKit virtual machines -- from building images with embedded templates, to running them via QEMU or Hyperkit, to offering a portable development environment with shell access, project mounting, test execution, and Claude AI integration. This is **not** a Docker wrapper. It runs real VMs from LinuxKit images (ISO, qcow2, VMDK, raw) using platform-native acceleration (KVM on Linux, HVF on macOS, Hyperkit where available). @@ -13,7 +13,7 @@ This is **not** a Docker wrapper. It runs real VMs from LinuxKit images (ISO, qc ## Module path ``` -forge.lthn.ai/core/go-container +dappco.re/go/core/container ``` Requires **Go 1.26+**. @@ -26,8 +26,8 @@ Requires **Go 1.26+**. ```go import ( "context" - container "forge.lthn.ai/core/go-container" - "forge.lthn.ai/core/go-io" + container "dappco.re/go/core/container" + "dappco.re/go/core/io" ) manager, err := container.NewLinuxKitManager(io.Local) @@ -54,8 +54,8 @@ fmt.Printf("Started container %s (PID %d)\n", c.ID, c.PID) ```go import ( - "forge.lthn.ai/core/go-container/devenv" - "forge.lthn.ai/core/go-io" + "dappco.re/go/core/container/devenv" + "dappco.re/go/core/io" ) 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 ```go -import container "forge.lthn.ai/core/go-container" +import container "dappco.re/go/core/container" // List available templates (built-in + user-defined) templates := container.ListTemplates() @@ -95,24 +95,24 @@ content, err := container.ApplyTemplate("core-dev", map[string]string{ | Package | Import path | Purpose | |---------|-------------|---------| -| `container` (root) | `forge.lthn.ai/core/go-container` | Container struct, Manager interface, hypervisor abstraction, LinuxKit manager, state persistence, template engine | -| `devenv` | `forge.lthn.ai/core/go-container/devenv` | Portable dev environment orchestration: boot, shell, serve, test, Claude sandbox, image management | -| `sources` | `forge.lthn.ai/core/go-container/sources` | Image download backends: CDN and GitHub Releases with progress reporting | -| `cmd/vm` | `forge.lthn.ai/core/go-container/cmd/vm` | CLI commands (`core vm run`, `core vm ps`, `core vm stop`, `core vm logs`, `core vm exec`, `core vm templates`) | +| `container` (root) | `dappco.re/go/core/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 | +| `sources` | `dappco.re/go/core/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`) | ## Dependencies | Module | Purpose | |--------|---------| -| `forge.lthn.ai/core/go-io` | File system abstraction (`Medium` interface), process utilities | +| `dappco.re/go/core/io` | File system abstraction (`Medium` interface), process utilities | | `forge.lthn.ai/core/config` | Configuration loading (used by `devenv` for `~/.core/config.yaml`) | -| `forge.lthn.ai/core/go-i18n` | Internationalised UI strings (used by `cmd/vm`) | +| `dappco.re/go/core/i18n` | Internationalised UI strings (used by `cmd/vm`) | | `forge.lthn.ai/core/cli` | CLI framework (used by `cmd/vm` for command registration) | | `github.com/stretchr/testify` | Test assertions | | `gopkg.in/yaml.v3` | YAML parsing for test configuration | -The root `container` package has only two direct dependencies: `go-io` and the standard library. The `devenv` and `cmd/vm` packages pull in the heavier dependencies. +The root `container` package has only two direct dependencies: `core/io` and the standard library. The `devenv` and `cmd/vm` packages pull in the heavier dependencies. ## CLI commands diff --git a/go.mod b/go.mod index bc04adf..e4837e6 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,24 @@ -module forge.lthn.ai/core/go-container +module dappco.re/go/core/container go 1.26.0 require ( + dappco.re/go/core v0.8.0-alpha.1 + dappco.re/go/core/i18n v0.2.0 + dappco.re/go/core/io v0.2.0 + dappco.re/go/core/log v0.1.0 forge.lthn.ai/core/cli v0.3.7 forge.lthn.ai/core/config v0.1.8 - forge.lthn.ai/core/go-i18n v0.1.7 - forge.lthn.ai/core/go-io v0.1.7 - forge.lthn.ai/core/go-log v0.0.4 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) require ( forge.lthn.ai/core/go v0.3.3 // indirect + forge.lthn.ai/core/go-i18n v0.1.7 // indirect forge.lthn.ai/core/go-inference v0.1.6 // indirect + forge.lthn.ai/core/go-io v0.1.7 // indirect + forge.lthn.ai/core/go-log v0.0.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect diff --git a/go.sum b/go.sum index b3757c3..430cc09 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI= +dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok= +dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= +dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= +dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= +dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg= diff --git a/hypervisor.go b/hypervisor.go index 67e8fa2..84c848a 100644 --- a/hypervisor.go +++ b/hypervisor.go @@ -2,14 +2,13 @@ package container import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" "runtime" - "strings" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/proc" ) // Hypervisor defines the interface for VM hypervisors. @@ -19,7 +18,7 @@ type Hypervisor interface { // Available checks if the hypervisor is available on the system. Available() bool // BuildCommand builds the command to run a VM with the given options. - BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) + BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) } // HypervisorOptions contains options for running a VM. @@ -47,6 +46,10 @@ type QemuHypervisor struct { } // NewQemuHypervisor creates a new QEMU hypervisor instance. +// +// Usage: +// +// hv := NewQemuHypervisor() func NewQemuHypervisor() *QemuHypervisor { return &QemuHypervisor{ Binary: "qemu-system-x86_64", @@ -60,20 +63,20 @@ func (q *QemuHypervisor) Name() string { // Available checks if QEMU is installed and accessible. func (q *QemuHypervisor) Available() bool { - _, err := exec.LookPath(q.Binary) + _, err := proc.LookPath(q.Binary) return err == nil } // BuildCommand creates the QEMU command for running a VM. -func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { +func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) { format := DetectImageFormat(image) if format == FormatUnknown { return nil, coreerr.E("QemuHypervisor.BuildCommand", "unknown image format: "+image, nil) } args := []string{ - "-m", fmt.Sprintf("%d", opts.Memory), - "-smp", fmt.Sprintf("%d", opts.CPUs), + "-m", core.Sprintf("%d", opts.Memory), + "-smp", core.Sprintf("%d", opts.CPUs), "-enable-kvm", } @@ -83,11 +86,11 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H args = append(args, "-cdrom", image) args = append(args, "-boot", "d") case FormatQCOW2: - args = append(args, "-drive", fmt.Sprintf("file=%s,format=qcow2", image)) + args = append(args, "-drive", core.Sprintf("file=%s,format=qcow2", image)) case FormatVMDK: - args = append(args, "-drive", fmt.Sprintf("file=%s,format=vmdk", image)) + args = append(args, "-drive", core.Sprintf("file=%s,format=vmdk", image)) case FormatRaw: - args = append(args, "-drive", fmt.Sprintf("file=%s,format=raw", image)) + args = append(args, "-drive", core.Sprintf("file=%s,format=raw", image)) } // Always run in nographic mode for container-like behavior @@ -99,10 +102,10 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H // Network with port forwarding netdev := "user,id=net0" if opts.SSHPort > 0 { - netdev += fmt.Sprintf(",hostfwd=tcp::%d-:22", opts.SSHPort) + netdev += core.Sprintf(",hostfwd=tcp::%d-:22", opts.SSHPort) } for hostPort, guestPort := range opts.Ports { - netdev += fmt.Sprintf(",hostfwd=tcp::%d-:%d", hostPort, guestPort) + netdev += core.Sprintf(",hostfwd=tcp::%d-:%d", hostPort, guestPort) } args = append(args, "-netdev", netdev) args = append(args, "-device", "virtio-net-pci,netdev=net0") @@ -110,10 +113,10 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H // Add 9p shares for volumes shareID := 0 for hostPath, guestPath := range opts.Volumes { - tag := fmt.Sprintf("share%d", shareID) + tag := core.Sprintf("share%d", shareID) args = append(args, - "-fsdev", fmt.Sprintf("local,id=%s,path=%s,security_model=none", tag, hostPath), - "-device", fmt.Sprintf("virtio-9p-pci,fsdev=%s,mount_tag=%s", tag, filepath.Base(guestPath)), + "-fsdev", core.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)), ) shareID++ } @@ -135,14 +138,12 @@ func (q *QemuHypervisor) BuildCommand(ctx context.Context, image string, opts *H } } - cmd := exec.CommandContext(ctx, q.Binary, args...) - return cmd, nil + return proc.NewCommandContext(ctx, q.Binary, args...), nil } // isKVMAvailable checks if KVM is available on the system. func isKVMAvailable() bool { - _, err := os.Stat("/dev/kvm") - return err == nil + return coreio.Local.Exists("/dev/kvm") } // HyperkitHypervisor implements Hypervisor for macOS Hyperkit. @@ -152,6 +153,10 @@ type HyperkitHypervisor struct { } // NewHyperkitHypervisor creates a new Hyperkit hypervisor instance. +// +// Usage: +// +// hv := NewHyperkitHypervisor() func NewHyperkitHypervisor() *HyperkitHypervisor { return &HyperkitHypervisor{ Binary: "hyperkit", @@ -168,20 +173,20 @@ func (h *HyperkitHypervisor) Available() bool { if runtime.GOOS != "darwin" { return false } - _, err := exec.LookPath(h.Binary) + _, err := proc.LookPath(h.Binary) return err == nil } // BuildCommand creates the Hyperkit command for running a VM. -func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { +func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) { format := DetectImageFormat(image) if format == FormatUnknown { return nil, coreerr.E("HyperkitHypervisor.BuildCommand", "unknown image format: "+image, nil) } args := []string{ - "-m", fmt.Sprintf("%dM", opts.Memory), - "-c", fmt.Sprintf("%d", opts.CPUs), + "-m", core.Sprintf("%dM", opts.Memory), + "-c", core.Sprintf("%d", opts.CPUs), "-A", // ACPI "-u", // Unlimited console output "-s", "0:0,hostbridge", @@ -192,9 +197,9 @@ func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opt // Add PCI slot for disk (slot 2) switch format { case FormatISO: - args = append(args, "-s", fmt.Sprintf("2:0,ahci-cd,%s", image)) + args = append(args, "-s", core.Sprintf("2:0,ahci-cd,%s", image)) case FormatQCOW2, FormatVMDK, FormatRaw: - args = append(args, "-s", fmt.Sprintf("2:0,virtio-blk,%s", image)) + args = append(args, "-s", core.Sprintf("2:0,virtio-blk,%s", image)) } // Network with port forwarding (slot 3) @@ -203,24 +208,27 @@ func (h *HyperkitHypervisor) BuildCommand(ctx context.Context, image string, opt // Hyperkit uses slirp for user networking with port forwarding portForwards := make([]string, 0) if opts.SSHPort > 0 { - portForwards = append(portForwards, fmt.Sprintf("tcp:%d:22", opts.SSHPort)) + portForwards = append(portForwards, core.Sprintf("tcp:%d:22", opts.SSHPort)) } for hostPort, guestPort := range opts.Ports { - portForwards = append(portForwards, fmt.Sprintf("tcp:%d:%d", hostPort, guestPort)) + portForwards = append(portForwards, core.Sprintf("tcp:%d:%d", hostPort, guestPort)) } if len(portForwards) > 0 { - netArgs += "," + strings.Join(portForwards, ",") + netArgs += "," + core.Join(",", portForwards...) } } args = append(args, "-s", "3:0,"+netArgs) - cmd := exec.CommandContext(ctx, h.Binary, args...) - return cmd, nil + return proc.NewCommandContext(ctx, h.Binary, args...), nil } // DetectImageFormat determines the image format from its file extension. +// +// Usage: +// +// format := DetectImageFormat("/tmp/core-dev.qcow2") func DetectImageFormat(path string) ImageFormat { - ext := strings.ToLower(filepath.Ext(path)) + ext := core.Lower(core.PathExt(path)) switch ext { case ".iso": return FormatISO @@ -236,6 +244,10 @@ func DetectImageFormat(path string) ImageFormat { } // DetectHypervisor returns the best available hypervisor for the current platform. +// +// Usage: +// +// hv, err := DetectHypervisor() func DetectHypervisor() (Hypervisor, error) { // On macOS, prefer Hyperkit if available, fall back to QEMU if runtime.GOOS == "darwin" { @@ -255,8 +267,12 @@ func DetectHypervisor() (Hypervisor, error) { } // GetHypervisor returns a specific hypervisor by name. +// +// Usage: +// +// hv, err := GetHypervisor("qemu") func GetHypervisor(name string) (Hypervisor, error) { - switch strings.ToLower(name) { + switch core.Lower(name) { case "qemu": h := NewQemuHypervisor() if !h.Available() { diff --git a/hypervisor_test.go b/hypervisor_test.go index e5c9964..2453f47 100644 --- a/hypervisor_test.go +++ b/hypervisor_test.go @@ -20,7 +20,7 @@ func TestQemuHypervisor_Available_Good(t *testing.T) { assert.IsType(t, true, available) } -func TestQemuHypervisor_Available_Bad_InvalidBinary(t *testing.T) { +func TestQemuHypervisor_AvailableInvalidBinary_Bad(t *testing.T) { q := &QemuHypervisor{ Binary: "nonexistent-qemu-binary-that-does-not-exist", } @@ -44,7 +44,7 @@ func TestHyperkitHypervisor_Available_Good(t *testing.T) { } } -func TestHyperkitHypervisor_Available_Bad_NotDarwin(t *testing.T) { +func TestHyperkitHypervisor_AvailableNotDarwin_Bad(t *testing.T) { if runtime.GOOS == "darwin" { t.Skip("This test only runs on non-darwin systems") } @@ -56,7 +56,7 @@ func TestHyperkitHypervisor_Available_Bad_NotDarwin(t *testing.T) { assert.False(t, available, "Hyperkit should not be available on non-darwin systems") } -func TestHyperkitHypervisor_Available_Bad_InvalidBinary(t *testing.T) { +func TestHyperkitHypervisor_AvailableInvalidBinary_Bad(t *testing.T) { h := &HyperkitHypervisor{ Binary: "nonexistent-hyperkit-binary-that-does-not-exist", } @@ -66,7 +66,7 @@ func TestHyperkitHypervisor_Available_Bad_InvalidBinary(t *testing.T) { assert.False(t, available) } -func TestIsKVMAvailable_Good(t *testing.T) { +func TestHypervisor_IsKVMAvailable_Good(t *testing.T) { // This test verifies the function runs without error // The actual result depends on the system result := isKVMAvailable() @@ -80,7 +80,7 @@ func TestIsKVMAvailable_Good(t *testing.T) { } } -func TestDetectHypervisor_Good(t *testing.T) { +func TestHypervisor_DetectHypervisor_Good(t *testing.T) { // DetectHypervisor tries to find an available hypervisor hv, err := DetectHypervisor() @@ -95,7 +95,7 @@ func TestDetectHypervisor_Good(t *testing.T) { } } -func TestGetHypervisor_Good_Qemu(t *testing.T) { +func TestGetHypervisor_Qemu_Good(t *testing.T) { hv, err := GetHypervisor("qemu") // Depends on whether qemu is installed @@ -107,7 +107,7 @@ func TestGetHypervisor_Good_Qemu(t *testing.T) { } } -func TestGetHypervisor_Good_QemuUppercase(t *testing.T) { +func TestGetHypervisor_QemuUppercase_Good(t *testing.T) { hv, err := GetHypervisor("QEMU") // Depends on whether qemu is installed @@ -119,7 +119,7 @@ func TestGetHypervisor_Good_QemuUppercase(t *testing.T) { } } -func TestGetHypervisor_Good_Hyperkit(t *testing.T) { +func TestGetHypervisor_Hyperkit_Good(t *testing.T) { hv, err := GetHypervisor("hyperkit") // On non-darwin systems, should always fail @@ -137,14 +137,14 @@ func TestGetHypervisor_Good_Hyperkit(t *testing.T) { } } -func TestGetHypervisor_Bad_Unknown(t *testing.T) { +func TestGetHypervisor_Unknown_Bad(t *testing.T) { _, err := GetHypervisor("unknown-hypervisor") assert.Error(t, err) assert.Contains(t, err.Error(), "unknown hypervisor") } -func TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes(t *testing.T) { +func TestQemuHypervisor_BuildCommandWithPortsAndVolumes_Good(t *testing.T) { q := NewQemuHypervisor() ctx := context.Background() @@ -172,7 +172,7 @@ func TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes(t *testing.T) { assert.Contains(t, args, "4") } -func TestQemuHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { +func TestQemuHypervisor_BuildCommandQCow2Format_Good(t *testing.T) { q := NewQemuHypervisor() ctx := context.Background() @@ -192,7 +192,7 @@ func TestQemuHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { assert.True(t, found, "Should have qcow2 drive argument") } -func TestQemuHypervisor_BuildCommand_Good_VMDKFormat(t *testing.T) { +func TestQemuHypervisor_BuildCommandVMDKFormat_Good(t *testing.T) { q := NewQemuHypervisor() ctx := context.Background() @@ -212,7 +212,7 @@ func TestQemuHypervisor_BuildCommand_Good_VMDKFormat(t *testing.T) { assert.True(t, found, "Should have vmdk drive argument") } -func TestQemuHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { +func TestQemuHypervisor_BuildCommandRawFormat_Good(t *testing.T) { q := NewQemuHypervisor() ctx := context.Background() @@ -232,7 +232,7 @@ func TestQemuHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { assert.True(t, found, "Should have raw drive argument") } -func TestHyperkitHypervisor_BuildCommand_Good_WithPorts(t *testing.T) { +func TestHyperkitHypervisor_BuildCommandWithPorts_Good(t *testing.T) { h := NewHyperkitHypervisor() ctx := context.Background() @@ -255,7 +255,7 @@ func TestHyperkitHypervisor_BuildCommand_Good_WithPorts(t *testing.T) { assert.Contains(t, args, "2") } -func TestHyperkitHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { +func TestHyperkitHypervisor_BuildCommandQCow2Format_Good(t *testing.T) { h := NewHyperkitHypervisor() ctx := context.Background() @@ -266,7 +266,7 @@ func TestHyperkitHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { assert.NotNil(t, cmd) } -func TestHyperkitHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { +func TestHyperkitHypervisor_BuildCommandRawFormat_Good(t *testing.T) { h := NewHyperkitHypervisor() ctx := context.Background() @@ -277,7 +277,7 @@ func TestHyperkitHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { assert.NotNil(t, cmd) } -func TestHyperkitHypervisor_BuildCommand_Good_NoPorts(t *testing.T) { +func TestHyperkitHypervisor_BuildCommandNoPorts_Good(t *testing.T) { h := NewHyperkitHypervisor() ctx := context.Background() @@ -293,7 +293,7 @@ func TestHyperkitHypervisor_BuildCommand_Good_NoPorts(t *testing.T) { assert.NotNil(t, cmd) } -func TestQemuHypervisor_BuildCommand_Good_NoSSHPort(t *testing.T) { +func TestQemuHypervisor_BuildCommandNoSSHPort_Good(t *testing.T) { q := NewQemuHypervisor() ctx := context.Background() @@ -309,7 +309,7 @@ func TestQemuHypervisor_BuildCommand_Good_NoSSHPort(t *testing.T) { assert.NotNil(t, cmd) } -func TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { +func TestQemuHypervisor_BuildCommandUnknownFormat_Bad(t *testing.T) { q := NewQemuHypervisor() ctx := context.Background() @@ -320,7 +320,7 @@ func TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { assert.Contains(t, err.Error(), "unknown image format") } -func TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { +func TestHyperkitHypervisor_BuildCommandUnknownFormat_Bad(t *testing.T) { h := NewHyperkitHypervisor() ctx := context.Background() @@ -336,7 +336,7 @@ func TestHyperkitHypervisor_Name_Good(t *testing.T) { assert.Equal(t, "hyperkit", h.Name()) } -func TestHyperkitHypervisor_BuildCommand_Good_ISOFormat(t *testing.T) { +func TestHyperkitHypervisor_BuildCommandISOFormat_Good(t *testing.T) { h := NewHyperkitHypervisor() ctx := context.Background() diff --git a/internal/coreutil/coreutil.go b/internal/coreutil/coreutil.go new file mode 100644 index 0000000..b5cb3c0 --- /dev/null +++ b/internal/coreutil/coreutil.go @@ -0,0 +1,76 @@ +package coreutil + +import ( + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" +) + +// DirSep returns the active directory separator. +func DirSep() string { + if ds := core.Env("DS"); ds != "" { + return ds + } + return "/" +} + +// JoinPath joins path segments using the active directory separator. +func JoinPath(parts ...string) string { + if len(parts) == 0 { + return "" + } + return core.CleanPath(core.Join(DirSep(), parts...), DirSep()) +} + +// HomeDir returns the current home directory, honouring test-time env overrides. +func HomeDir() string { + if home := core.Env("CORE_HOME"); home != "" { + return home + } + if home := core.Env("HOME"); home != "" { + return home + } + if home := core.Env("USERPROFILE"); home != "" { + return home + } + return core.Env("DIR_HOME") +} + +// CurrentDir returns the current working directory, honouring shell PWD. +func CurrentDir() string { + if pwd := core.Env("PWD"); pwd != "" { + return pwd + } + return core.Env("DIR_CWD") +} + +// TempDir returns the process temp directory, honouring TMPDIR. +func TempDir() string { + if dir := core.Env("TMPDIR"); dir != "" { + return dir + } + return core.Env("DIR_TMP") +} + +// AbsPath resolves a path against the current working directory. +func AbsPath(path string) string { + if path == "" { + return CurrentDir() + } + if core.PathIsAbs(path) { + return core.CleanPath(path, DirSep()) + } + return JoinPath(CurrentDir(), path) +} + +// MkdirTemp creates a temporary directory with a deterministic Core-generated name. +func MkdirTemp(prefix string) (string, error) { + name := prefix + if name == "" { + name = "tmp-" + } + path := JoinPath(TempDir(), core.Concat(name, core.ID())) + if err := coreio.Local.EnsureDir(path); err != nil { + return "", err + } + return path, nil +} diff --git a/internal/proc/proc.go b/internal/proc/proc.go new file mode 100644 index 0000000..a8a2681 --- /dev/null +++ b/internal/proc/proc.go @@ -0,0 +1,401 @@ +package proc + +import ( + "context" + goio "io" + "sync" + "syscall" + + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" + + "dappco.re/go/core/container/internal/coreutil" +) + +type fdProvider interface { + Fd() uintptr +} + +type Process struct { + Pid int +} + +func (p *Process) Kill() error { + if p == nil || p.Pid <= 0 { + return nil + } + return syscall.Kill(p.Pid, syscall.SIGKILL) +} + +func (p *Process) Signal(sig syscall.Signal) error { + if p == nil || p.Pid <= 0 { + return nil + } + return syscall.Kill(p.Pid, sig) +} + +type Command struct { + Path string + Args []string + Dir string + Env []string + Stdin goio.Reader + Stdout goio.Writer + Stderr goio.Writer + + Process *Process + + ctx context.Context + + started bool + done chan struct{} + waitErr error + waited bool + waitMu sync.Mutex + + stdoutPipe *pipeReader + stderrPipe *pipeReader +} + +type pipeReader struct { + fd int + childFD int +} + +func (p *pipeReader) Read(data []byte) (int, error) { + n, err := syscall.Read(p.fd, data) + if err != nil { + return n, err + } + if n == 0 { + return 0, goio.EOF + } + return n, nil +} + +func (p *pipeReader) Close() error { + var first error + if p.fd >= 0 { + if err := syscall.Close(p.fd); err != nil { + first = err + } + p.fd = -1 + } + if p.childFD >= 0 { + if err := syscall.Close(p.childFD); err != nil && first == nil { + first = err + } + p.childFD = -1 + } + return first +} + +type stdioReader struct { + fd int +} + +func (s *stdioReader) Read(data []byte) (int, error) { + n, err := syscall.Read(s.fd, data) + if err != nil { + return n, err + } + if n == 0 { + return 0, goio.EOF + } + return n, nil +} + +func (s *stdioReader) Close() error { return nil } + +func (s *stdioReader) Fd() uintptr { return uintptr(s.fd) } + +type stdioWriter struct { + fd int +} + +func (s *stdioWriter) Write(data []byte) (int, error) { + total := 0 + for len(data) > 0 { + n, err := syscall.Write(s.fd, data) + total += n + if err != nil { + return total, err + } + data = data[n:] + } + return total, nil +} + +func (s *stdioWriter) Close() error { return nil } + +func (s *stdioWriter) Fd() uintptr { return uintptr(s.fd) } + +var ( + Stdin goio.ReadCloser = &stdioReader{fd: 0} + Stdout goio.WriteCloser = &stdioWriter{fd: 1} + Stderr goio.WriteCloser = &stdioWriter{fd: 2} +) + +var ( + nullFD int + nullOnce sync.Once + nullErr error +) + +func Environ() []string { + return syscall.Environ() +} + +func NewCommandContext(ctx context.Context, name string, args ...string) *Command { + if ctx == nil { + ctx = context.Background() + } + return &Command{ + Path: name, + Args: append([]string{name}, args...), + ctx: ctx, + } +} + +func NewCommand(name string, args ...string) *Command { + return NewCommandContext(context.Background(), name, args...) +} + +func LookPath(name string) (string, error) { + if name == "" { + return "", core.E("proc.LookPath", "empty command", nil) + } + if core.Contains(name, "/") || core.Contains(name, "\\") { + if isExecutable(name) { + return name, nil + } + return "", core.E("proc.LookPath", core.Concat("executable not found: ", name), nil) + } + + pathEnv := core.Env("PATH") + sep := core.Env("PS") + if sep == "" { + sep = ":" + } + + for _, dir := range core.Split(pathEnv, sep) { + if dir == "" { + dir = "." + } + candidate := coreutil.JoinPath(dir, name) + if isExecutable(candidate) { + return candidate, nil + } + } + + return "", core.E("proc.LookPath", core.Concat("executable not found: ", name), nil) +} + +func (c *Command) StdoutPipe() (goio.ReadCloser, error) { + if c.started { + return nil, core.E("proc.Command.StdoutPipe", "command already started", nil) + } + if c.stdoutPipe != nil { + return nil, core.E("proc.Command.StdoutPipe", "stdout pipe already requested", nil) + } + fds := make([]int, 2) + if err := syscall.Pipe(fds); err != nil { + return nil, err + } + c.stdoutPipe = &pipeReader{fd: fds[0], childFD: fds[1]} + return c.stdoutPipe, nil +} + +func (c *Command) StderrPipe() (goio.ReadCloser, error) { + if c.started { + return nil, core.E("proc.Command.StderrPipe", "command already started", nil) + } + if c.stderrPipe != nil { + return nil, core.E("proc.Command.StderrPipe", "stderr pipe already requested", nil) + } + fds := make([]int, 2) + if err := syscall.Pipe(fds); err != nil { + return nil, err + } + c.stderrPipe = &pipeReader{fd: fds[0], childFD: fds[1]} + return c.stderrPipe, nil +} + +func (c *Command) Start() error { + if c.started { + return core.E("proc.Command.Start", "command already started", nil) + } + if c.ctx != nil { + if err := c.ctx.Err(); err != nil { + return err + } + } + + path, err := LookPath(c.Path) + if err != nil { + return err + } + + files := []uintptr{ + c.inputFD(), + c.outputFD(c.stdoutPipe, c.Stdout), + c.outputFD(c.stderrPipe, c.Stderr), + } + + env := c.Env + if env == nil { + env = Environ() + } + + pid, _, err := syscall.StartProcess(path, c.Args, &syscall.ProcAttr{ + Dir: c.Dir, + Env: env, + Files: files, + }) + if err != nil { + return err + } + + c.Process = &Process{Pid: pid} + c.done = make(chan struct{}) + c.started = true + c.closeChildPipeEnds() + c.watchContext() + + return nil +} + +func (c *Command) Run() error { + if err := c.Start(); err != nil { + return err + } + return c.Wait() +} + +func (c *Command) Output() ([]byte, error) { + if c.Stdout != nil { + return nil, core.E("proc.Command.Output", "stdout already configured", nil) + } + reader, err := c.StdoutPipe() + if err != nil { + return nil, err + } + defer func() { _ = reader.Close() }() + + if err := c.Start(); err != nil { + return nil, err + } + + data, readErr := goio.ReadAll(reader) + waitErr := c.Wait() + if readErr != nil { + return nil, readErr + } + if waitErr != nil { + return data, waitErr + } + return data, nil +} + +func (c *Command) Wait() error { + c.waitMu.Lock() + defer c.waitMu.Unlock() + + if !c.started { + return core.E("proc.Command.Wait", "command not started", nil) + } + if c.waited { + return c.waitErr + } + + var status syscall.WaitStatus + for { + _, err := syscall.Wait4(c.Process.Pid, &status, 0, nil) + if err == syscall.EINTR { + continue + } + if err != nil { + c.waitErr = err + break + } + if status.Exited() && status.ExitStatus() != 0 { + c.waitErr = core.E("proc.Command.Wait", core.Sprintf("exit status %d", status.ExitStatus()), nil) + } + if status.Signaled() { + c.waitErr = core.E("proc.Command.Wait", core.Sprintf("signal %d", status.Signal()), nil) + } + break + } + + c.waited = true + close(c.done) + return c.waitErr +} + +func (c *Command) inputFD() uintptr { + if c.Stdin == nil { + return uintptr(openNull()) + } + if file, ok := c.Stdin.(fdProvider); ok { + return file.Fd() + } + return uintptr(openNull()) +} + +func (c *Command) outputFD(pipe *pipeReader, writer goio.Writer) uintptr { + if pipe != nil { + return uintptr(pipe.childFD) + } + if writer == nil { + return uintptr(openNull()) + } + if file, ok := writer.(fdProvider); ok { + return file.Fd() + } + return uintptr(openNull()) +} + +func (c *Command) closeChildPipeEnds() { + if c.stdoutPipe != nil && c.stdoutPipe.childFD >= 0 { + _ = syscall.Close(c.stdoutPipe.childFD) + c.stdoutPipe.childFD = -1 + } + if c.stderrPipe != nil && c.stderrPipe.childFD >= 0 { + _ = syscall.Close(c.stderrPipe.childFD) + c.stderrPipe.childFD = -1 + } +} + +func (c *Command) watchContext() { + if c.ctx == nil || c.done == nil || c.Process == nil { + return + } + go func() { + select { + case <-c.ctx.Done(): + _ = c.Process.Kill() + case <-c.done: + } + }() +} + +func isExecutable(path string) bool { + info, err := coreio.Local.Stat(path) + if err != nil { + return false + } + if !info.Mode().IsRegular() { + return false + } + return info.Mode()&0111 != 0 +} + +func openNull() int { + nullOnce.Do(func() { + nullFD, nullErr = syscall.Open("/dev/null", syscall.O_RDWR, 0) + }) + if nullErr != nil { + return 2 + } + return nullFD +} diff --git a/kb/Home.md b/kb/Home.md index 8b71700..59f97a6 100644 --- a/kb/Home.md +++ b/kb/Home.md @@ -1,6 +1,6 @@ # go-container -Module: `forge.lthn.ai/core/go-container` +Module: `dappco.re/go/core/container` Container runtime for managing LinuxKit VMs as lightweight containers. Supports running LinuxKit images (ISO, qcow2, vmdk, raw) via QEMU or Hyperkit hypervisors. Includes a dev environment system for Claude Code agents and development workflows. @@ -55,7 +55,7 @@ Container runtime for managing LinuxKit VMs as lightweight containers. Supports ## Usage ```go -import "forge.lthn.ai/core/go-container" +import "dappco.re/go/core/container" // Auto-detect hypervisor hv, _ := container.DetectHypervisor() diff --git a/kb/Hypervisors.md b/kb/Hypervisors.md index 9f0c711..49e599e 100644 --- a/kb/Hypervisors.md +++ b/kb/Hypervisors.md @@ -1,6 +1,6 @@ # Hypervisors -Module: `forge.lthn.ai/core/go-container` +Module: `dappco.re/go/core/container` ## Interface diff --git a/linuxkit.go b/linuxkit.go index f891bf8..c248df1 100644 --- a/linuxkit.go +++ b/linuxkit.go @@ -3,15 +3,15 @@ package container import ( "bufio" "context" - "fmt" goio "io" - "os" - "os/exec" "syscall" "time" - "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/proc" ) // LinuxKitManager implements the Manager interface for LinuxKit VMs. @@ -22,6 +22,10 @@ type LinuxKitManager struct { } // NewLinuxKitManager creates a new LinuxKit manager with auto-detected hypervisor. +// +// Usage: +// +// manager, err := NewLinuxKitManager(io.Local) func NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error) { statePath, err := DefaultStatePath() if err != nil { @@ -46,6 +50,10 @@ func NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error) { } // 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 { return &LinuxKitManager{ state: state, @@ -119,7 +127,7 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions } // Create log file - logFile, err := os.Create(logPath) + logFile, err := io.Local.Create(logPath) if err != nil { return nil, coreerr.E("LinuxKitManager.Run", "failed to create log file", err) } @@ -196,11 +204,11 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions // Copy output to both log and stdout go func() { - mw := goio.MultiWriter(logFile, os.Stdout) + mw := goio.MultiWriter(logFile, proc.Stdout) _, _ = goio.Copy(mw, stdout) }() go func() { - mw := goio.MultiWriter(logFile, os.Stderr) + mw := goio.MultiWriter(logFile, proc.Stderr) _, _ = goio.Copy(mw, stderr) }() @@ -220,7 +228,7 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions } // waitForExit monitors a detached process and updates state when it exits. -func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) { +func (m *LinuxKitManager) waitForExit(id string, cmd *proc.Command) { err := cmd.Wait() container, ok := m.state.Get(id) @@ -249,16 +257,7 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error { } // Find the process - 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 { + if err := syscall.Kill(container.PID, syscall.SIGTERM); err != nil { // Process might already be gone container.Status = StatusStopped _ = m.state.Update(container) @@ -267,28 +266,23 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error { // Honour already-cancelled contexts before waiting if err := ctx.Err(); err != nil { - _ = process.Signal(syscall.SIGKILL) + _ = syscall.Kill(container.PID, syscall.SIGKILL) return err } - // Wait for graceful shutdown with timeout - done := make(chan struct{}) - go func() { - _, _ = process.Wait() - close(done) - }() + deadline := time.After(10 * time.Second) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() - select { - case <-done: - // Process exited gracefully - case <-time.After(10 * time.Second): - // Force kill - _ = process.Signal(syscall.SIGKILL) - <-done - case <-ctx.Done(): - // Context cancelled - _ = process.Signal(syscall.SIGKILL) - return ctx.Err() + for isProcessRunning(container.PID) { + select { + case <-deadline: + _ = syscall.Kill(container.PID, syscall.SIGKILL) + case <-ctx.Done(): + _ = syscall.Kill(container.PID, syscall.SIGKILL) + return ctx.Err() + case <-ticker.C: + } } container.Status = StatusStopped @@ -317,14 +311,10 @@ func (m *LinuxKitManager) List(ctx context.Context) ([]*Container, error) { // isProcessRunning checks if a process with the given PID is still running. func isProcessRunning(pid int) bool { - process, err := os.FindProcess(pid) - if err != nil { + if pid <= 0 { return false } - - // On Unix, FindProcess always succeeds, so we need to send signal 0 to check - err = process.Signal(syscall.Signal(0)) - return err == nil + return syscall.Kill(pid, syscall.Signal(0)) == nil } // Logs returns a reader for the container's log output. @@ -436,7 +426,7 @@ func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) err // Build SSH command sshArgs := []string{ - "-p", fmt.Sprintf("%d", sshPort), + "-p", core.Sprintf("%d", sshPort), "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "LogLevel=ERROR", @@ -444,10 +434,10 @@ func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) err } sshArgs = append(sshArgs, cmd...) - sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...) - sshCmd.Stdin = os.Stdin - sshCmd.Stdout = os.Stdout - sshCmd.Stderr = os.Stderr + sshCmd := proc.NewCommandContext(ctx, "ssh", sshArgs...) + sshCmd.Stdin = proc.Stdin + sshCmd.Stdout = proc.Stdout + sshCmd.Stderr = proc.Stderr return sshCmd.Run() } diff --git a/linuxkit_test.go b/linuxkit_test.go index da748e8..df09747 100644 --- a/linuxkit_test.go +++ b/linuxkit_test.go @@ -2,13 +2,14 @@ package container import ( "context" - "os" - "os/exec" - "path/filepath" + "syscall" "testing" "time" - "forge.lthn.ai/core/go-io" + core "dappco.re/go/core" + "dappco.re/go/core/container/internal/coreutil" + "dappco.re/go/core/container/internal/proc" + "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -39,30 +40,30 @@ func (m *MockHypervisor) Available() bool { return m.available } -func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*exec.Cmd, error) { +func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) { m.lastImage = image m.lastOpts = opts if m.buildErr != nil { return nil, m.buildErr } // Return a simple command that exits quickly - return exec.CommandContext(ctx, m.commandToRun, "test"), nil + return proc.NewCommandContext(ctx, m.commandToRun, "test"), nil } // newTestManager creates a LinuxKitManager with mock hypervisor for testing. // Uses manual temp directory management to avoid race conditions with t.TempDir cleanup. func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) { - tmpDir, err := os.MkdirTemp("", "linuxkit-test-*") + tmpDir, err := coreutil.MkdirTemp("linuxkit-test-") require.NoError(t, err) // Manual cleanup that handles race conditions with state file writes t.Cleanup(func() { // Give any pending file operations time to complete time.Sleep(10 * time.Millisecond) - _ = os.RemoveAll(tmpDir) + _ = io.Local.DeleteAll(tmpDir) }) - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") state, err := LoadState(statePath) require.NoError(t, err) @@ -73,9 +74,9 @@ func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) { return manager, mock, tmpDir } -func TestNewLinuxKitManagerWithHypervisor_Good(t *testing.T) { +func TestLinuxKit_NewLinuxKitManagerWithHypervisor_Good(t *testing.T) { tmpDir := t.TempDir() - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") state, _ := LoadState(statePath) mock := NewMockHypervisor() @@ -86,12 +87,12 @@ func TestNewLinuxKitManagerWithHypervisor_Good(t *testing.T) { assert.Equal(t, mock, manager.Hypervisor()) } -func TestLinuxKitManager_Run_Good_Detached(t *testing.T) { +func TestLinuxKitManager_RunDetached_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) // Create a test image file - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) + imagePath := coreutil.JoinPath(tmpDir, "test.iso") + err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use a command that runs briefly then exits @@ -125,11 +126,11 @@ func TestLinuxKitManager_Run_Good_Detached(t *testing.T) { time.Sleep(100 * time.Millisecond) } -func TestLinuxKitManager_Run_Good_DefaultValues(t *testing.T) { +func TestLinuxKitManager_RunDefaultValues_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) - imagePath := filepath.Join(tmpDir, "test.qcow2") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) + imagePath := coreutil.JoinPath(tmpDir, "test.qcow2") + err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) ctx := context.Background() @@ -150,7 +151,7 @@ func TestLinuxKitManager_Run_Good_DefaultValues(t *testing.T) { time.Sleep(50 * time.Millisecond) } -func TestLinuxKitManager_Run_Bad_ImageNotFound(t *testing.T) { +func TestLinuxKitManager_RunImageNotFound_Bad(t *testing.T) { manager, _, _ := newTestManager(t) ctx := context.Background() @@ -161,11 +162,11 @@ func TestLinuxKitManager_Run_Bad_ImageNotFound(t *testing.T) { assert.Contains(t, err.Error(), "image not found") } -func TestLinuxKitManager_Run_Bad_UnsupportedFormat(t *testing.T) { +func TestLinuxKitManager_RunUnsupportedFormat_Bad(t *testing.T) { manager, _, tmpDir := newTestManager(t) - imagePath := filepath.Join(tmpDir, "test.txt") - err := os.WriteFile(imagePath, []byte("not an image"), 0644) + imagePath := coreutil.JoinPath(tmpDir, "test.txt") + err := io.Local.Write(imagePath, "not an image") require.NoError(t, err) ctx := context.Background() @@ -201,7 +202,7 @@ func TestLinuxKitManager_Stop_Good(t *testing.T) { assert.Equal(t, StatusStopped, c.Status) } -func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) { +func TestLinuxKitManager_StopNotFound_Bad(t *testing.T) { manager, _, _ := newTestManager(t) ctx := context.Background() @@ -211,9 +212,9 @@ func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "container not found") } -func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) { +func TestLinuxKitManager_StopNotRunning_Bad(t *testing.T) { _, _, tmpDir := newTestManager(t) - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") state, err := LoadState(statePath) require.NoError(t, err) manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) @@ -233,7 +234,7 @@ func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) { func TestLinuxKitManager_List_Good(t *testing.T) { _, _, tmpDir := newTestManager(t) - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") state, err := LoadState(statePath) require.NoError(t, err) manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) @@ -248,9 +249,9 @@ func TestLinuxKitManager_List_Good(t *testing.T) { assert.Len(t, containers, 2) } -func TestLinuxKitManager_List_Good_VerifiesRunningStatus(t *testing.T) { +func TestLinuxKitManager_ListVerifiesRunningStatus_Good(t *testing.T) { _, _, tmpDir := newTestManager(t) - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") state, err := LoadState(statePath) require.NoError(t, err) manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) @@ -275,8 +276,8 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) { manager, _, tmpDir := newTestManager(t) // Create a log file manually - logsDir := filepath.Join(tmpDir, "logs") - require.NoError(t, os.MkdirAll(logsDir, 0755)) + logsDir := coreutil.JoinPath(tmpDir, "logs") + require.NoError(t, io.Local.EnsureDir(logsDir)) container := &Container{ID: "abc12345"} _ = manager.State().Add(container) @@ -286,8 +287,8 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) { logContent := "test log content\nline 2\n" logPath, err := LogPath("abc12345") require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755)) - require.NoError(t, os.WriteFile(logPath, []byte(logContent), 0644)) + require.NoError(t, io.Local.EnsureDir(core.PathDir(logPath))) + require.NoError(t, io.Local.Write(logPath, logContent)) ctx := context.Background() reader, err := manager.Logs(ctx, "abc12345", false) @@ -300,7 +301,7 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) { assert.Equal(t, logContent, string(buf[:n])) } -func TestLinuxKitManager_Logs_Bad_NotFound(t *testing.T) { +func TestLinuxKitManager_LogsNotFound_Bad(t *testing.T) { manager, _, _ := newTestManager(t) ctx := context.Background() @@ -310,7 +311,7 @@ func TestLinuxKitManager_Logs_Bad_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "container not found") } -func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) { +func TestLinuxKitManager_LogsNoLogFile_Bad(t *testing.T) { manager, _, _ := newTestManager(t) // Use a unique ID that won't have a log file @@ -333,7 +334,7 @@ func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) { } } -func TestLinuxKitManager_Exec_Bad_NotFound(t *testing.T) { +func TestLinuxKitManager_ExecNotFound_Bad(t *testing.T) { manager, _, _ := newTestManager(t) ctx := context.Background() @@ -343,7 +344,7 @@ func TestLinuxKitManager_Exec_Bad_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "container not found") } -func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) { +func TestLinuxKitManager_ExecNotRunning_Bad(t *testing.T) { manager, _, _ := newTestManager(t) container := &Container{ID: "abc12345", Status: StatusStopped} @@ -356,7 +357,7 @@ func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) { assert.Contains(t, err.Error(), "not running") } -func TestDetectImageFormat_Good(t *testing.T) { +func TestLinuxKit_DetectImageFormat_Good(t *testing.T) { tests := []struct { path string format ImageFormat @@ -378,7 +379,7 @@ func TestDetectImageFormat_Good(t *testing.T) { } } -func TestDetectImageFormat_Bad_Unknown(t *testing.T) { +func TestDetectImageFormat_Unknown_Bad(t *testing.T) { tests := []string{ "/path/to/image.txt", "/path/to/image", @@ -426,7 +427,7 @@ func TestQemuHypervisor_BuildCommand_Good(t *testing.T) { assert.Contains(t, args, "-nographic") } -func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) { +func TestLinuxKitManager_LogsFollow_Good(t *testing.T) { manager, _, _ := newTestManager(t) // Create a unique container ID @@ -438,10 +439,10 @@ func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) { // Create a log file at the expected location logPath, err := LogPath(uniqueID) require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755)) + require.NoError(t, io.Local.EnsureDir(core.PathDir(logPath))) // Write initial content - err = os.WriteFile(logPath, []byte("initial log content\n"), 0644) + err = io.Local.Write(logPath, "initial log content\n") require.NoError(t, err) // Create a cancellable context @@ -464,13 +465,13 @@ func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) { assert.NoError(t, reader.Close()) } -func TestFollowReader_Read_Good_WithData(t *testing.T) { +func TestFollowReader_ReadWithData_Good(t *testing.T) { tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "test.log") + logPath := coreutil.JoinPath(tmpDir, "test.log") // Create log file with content content := "test log line 1\ntest log line 2\n" - err := os.WriteFile(logPath, []byte(content), 0644) + err := io.Local.Write(logPath, content) require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -481,9 +482,9 @@ func TestFollowReader_Read_Good_WithData(t *testing.T) { defer func() { _ = reader.Close() }() // The followReader seeks to end, so we need to append more content - f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0644) + f, err := io.Local.Append(logPath) require.NoError(t, err) - _, err = f.WriteString("new line\n") + _, err = f.Write([]byte("new line\n")) require.NoError(t, err) require.NoError(t, f.Close()) @@ -497,12 +498,12 @@ func TestFollowReader_Read_Good_WithData(t *testing.T) { } } -func TestFollowReader_Read_Good_ContextCancel(t *testing.T) { +func TestFollowReader_ReadContextCancel_Good(t *testing.T) { tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "test.log") + logPath := coreutil.JoinPath(tmpDir, "test.log") // Create log file - err := os.WriteFile(logPath, []byte("initial content\n"), 0644) + err := io.Local.Write(logPath, "initial content\n") require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) @@ -523,9 +524,9 @@ func TestFollowReader_Read_Good_ContextCancel(t *testing.T) { func TestFollowReader_Close_Good(t *testing.T) { tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "test.log") + logPath := coreutil.JoinPath(tmpDir, "test.log") - err := os.WriteFile(logPath, []byte("content\n"), 0644) + err := io.Local.Write(logPath, "content\n") require.NoError(t, err) ctx := context.Background() @@ -541,19 +542,19 @@ func TestFollowReader_Close_Good(t *testing.T) { assert.Error(t, readErr) } -func TestNewFollowReader_Bad_FileNotFound(t *testing.T) { +func TestNewFollowReader_FileNotFound_Bad(t *testing.T) { ctx := context.Background() _, err := newFollowReader(ctx, io.Local, "/nonexistent/path/to/file.log") assert.Error(t, err) } -func TestLinuxKitManager_Run_Bad_BuildCommandError(t *testing.T) { +func TestLinuxKitManager_RunBuildCommandError_Bad(t *testing.T) { manager, mock, tmpDir := newTestManager(t) // Create a test image file - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) + imagePath := coreutil.JoinPath(tmpDir, "test.iso") + err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Configure mock to return an error @@ -567,12 +568,12 @@ func TestLinuxKitManager_Run_Bad_BuildCommandError(t *testing.T) { assert.Contains(t, err.Error(), "failed to build hypervisor command") } -func TestLinuxKitManager_Run_Good_Foreground(t *testing.T) { +func TestLinuxKitManager_RunForeground_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) // Create a test image file - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) + imagePath := coreutil.JoinPath(tmpDir, "test.iso") + err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use echo which exits quickly @@ -595,12 +596,12 @@ func TestLinuxKitManager_Run_Good_Foreground(t *testing.T) { assert.Equal(t, StatusStopped, container.Status) } -func TestLinuxKitManager_Stop_Good_ContextCancelled(t *testing.T) { +func TestLinuxKitManager_StopContextCancelled_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) // Create a test image file - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) + imagePath := coreutil.JoinPath(tmpDir, "test.iso") + err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use a command that takes a long time @@ -632,23 +633,23 @@ func TestLinuxKitManager_Stop_Good_ContextCancelled(t *testing.T) { assert.Equal(t, context.Canceled, err) } -func TestIsProcessRunning_Good_ExistingProcess(t *testing.T) { +func TestIsProcessRunning_ExistingProcess_Good(t *testing.T) { // Use our own PID which definitely exists - running := isProcessRunning(os.Getpid()) + running := isProcessRunning(syscall.Getpid()) assert.True(t, running) } -func TestIsProcessRunning_Bad_NonexistentProcess(t *testing.T) { +func TestIsProcessRunning_NonexistentProcess_Bad(t *testing.T) { // Use a PID that almost certainly doesn't exist running := isProcessRunning(999999) assert.False(t, running) } -func TestLinuxKitManager_Run_Good_WithPortsAndVolumes(t *testing.T) { +func TestLinuxKitManager_RunWithPortsAndVolumes_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) + imagePath := coreutil.JoinPath(tmpDir, "test.iso") + err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) ctx := context.Background() @@ -673,12 +674,12 @@ func TestLinuxKitManager_Run_Good_WithPortsAndVolumes(t *testing.T) { time.Sleep(50 * time.Millisecond) } -func TestFollowReader_Read_Bad_ReaderError(t *testing.T) { +func TestFollowReader_ReadReaderError_Bad(t *testing.T) { tmpDir := t.TempDir() - logPath := filepath.Join(tmpDir, "test.log") + logPath := coreutil.JoinPath(tmpDir, "test.log") // Create log file - err := os.WriteFile(logPath, []byte("content\n"), 0644) + err := io.Local.Write(logPath, "content\n") require.NoError(t, err) ctx := context.Background() @@ -694,11 +695,11 @@ func TestFollowReader_Read_Bad_ReaderError(t *testing.T) { assert.Error(t, readErr) } -func TestLinuxKitManager_Run_Bad_StartError(t *testing.T) { +func TestLinuxKitManager_RunStartError_Bad(t *testing.T) { manager, mock, tmpDir := newTestManager(t) - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) + imagePath := coreutil.JoinPath(tmpDir, "test.iso") + err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use a command that doesn't exist to cause Start() to fail @@ -715,11 +716,11 @@ func TestLinuxKitManager_Run_Bad_StartError(t *testing.T) { assert.Contains(t, err.Error(), "failed to start VM") } -func TestLinuxKitManager_Run_Bad_ForegroundStartError(t *testing.T) { +func TestLinuxKitManager_RunForegroundStartError_Bad(t *testing.T) { manager, mock, tmpDir := newTestManager(t) - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) + imagePath := coreutil.JoinPath(tmpDir, "test.iso") + err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use a command that doesn't exist to cause Start() to fail @@ -736,11 +737,11 @@ func TestLinuxKitManager_Run_Bad_ForegroundStartError(t *testing.T) { assert.Contains(t, err.Error(), "failed to start VM") } -func TestLinuxKitManager_Run_Good_ForegroundWithError(t *testing.T) { +func TestLinuxKitManager_RunForegroundWithError_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) - imagePath := filepath.Join(tmpDir, "test.iso") - err := os.WriteFile(imagePath, []byte("fake image"), 0644) + imagePath := coreutil.JoinPath(tmpDir, "test.iso") + err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use a command that exits with error @@ -759,7 +760,7 @@ func TestLinuxKitManager_Run_Good_ForegroundWithError(t *testing.T) { assert.Equal(t, StatusError, container.Status) } -func TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning(t *testing.T) { +func TestLinuxKitManager_StopProcessExitedWhileRunning_Good(t *testing.T) { manager, _, _ := newTestManager(t) // Add a "running" container with a process that has already exited diff --git a/sources/cdn.go b/sources/cdn.go index d301aee..d33a425 100644 --- a/sources/cdn.go +++ b/sources/cdn.go @@ -2,14 +2,14 @@ package sources import ( "context" - "fmt" goio "io" "net/http" - "os" - "path/filepath" - "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/coreutil" ) // CDNSource downloads images from a CDN or S3 bucket. @@ -21,6 +21,10 @@ type CDNSource struct { var _ ImageSource = (*CDNSource)(nil) // NewCDNSource creates a new CDN source. +// +// Usage: +// +// src := NewCDNSource(cfg) func NewCDNSource(cfg SourceConfig) *CDNSource { return &CDNSource{config: cfg} } @@ -38,7 +42,7 @@ func (s *CDNSource) Available() bool { // LatestVersion fetches version from manifest or returns "latest". func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) { // Try to fetch manifest.json for version info - url := fmt.Sprintf("%s/manifest.json", s.config.CDNURL) + url := core.Sprintf("%s/manifest.json", s.config.CDNURL) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "latest", nil @@ -56,7 +60,7 @@ func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) { // Download downloads the image from CDN. func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error { - url := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName) + url := core.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { @@ -70,7 +74,7 @@ func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, prog defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { - return coreerr.E("cdn.Download", fmt.Sprintf("HTTP %d", resp.StatusCode), nil) + return coreerr.E("cdn.Download", core.Sprintf("HTTP %d", resp.StatusCode), nil) } // Ensure dest directory exists @@ -79,8 +83,8 @@ func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, prog } // Create destination file - destPath := filepath.Join(dest, s.config.ImageName) - f, err := os.Create(destPath) + destPath := coreutil.JoinPath(dest, s.config.ImageName) + f, err := m.Create(destPath) if err != nil { return coreerr.E("cdn.Download", "create destination file", err) } diff --git a/sources/cdn_test.go b/sources/cdn_test.go index 7473d45..22d986c 100644 --- a/sources/cdn_test.go +++ b/sources/cdn_test.go @@ -2,18 +2,18 @@ package sources import ( "context" - "fmt" + goio "io" "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" - "forge.lthn.ai/core/go-io" + core "dappco.re/go/core" + "dappco.re/go/core/container/internal/coreutil" + "dappco.re/go/core/io" "github.com/stretchr/testify/assert" ) -func TestCDNSource_Good_Available(t *testing.T) { +func TestCDNSource_Available_Good(t *testing.T) { src := NewCDNSource(SourceConfig{ CDNURL: "https://images.example.com", ImageName: "core-devops-darwin-arm64.qcow2", @@ -23,7 +23,7 @@ func TestCDNSource_Good_Available(t *testing.T) { assert.True(t, src.Available()) } -func TestCDNSource_Bad_NoURL(t *testing.T) { +func TestCDNSource_NoURL_Bad(t *testing.T) { src := NewCDNSource(SourceConfig{ 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) { if r.URL.Path == "/manifest.json" { w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, `{"version": "1.2.3"}`) + _, _ = goio.WriteString(w, `{"version": "1.2.3"}`) } else { 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) { if r.URL.Path == "/test.img" { w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, content) + _, _ = goio.WriteString(w, content) } else { w.WriteHeader(http.StatusNotFound) } @@ -80,9 +80,9 @@ func TestCDNSource_Download_Good(t *testing.T) { assert.True(t, progressCalled) // Verify file content - data, err := os.ReadFile(filepath.Join(dest, imageName)) + data, err := io.Local.Read(coreutil.JoinPath(dest, imageName)) assert.NoError(t, err) - assert.Equal(t, content, string(data)) + assert.Equal(t, content, data) } func TestCDNSource_Download_Bad(t *testing.T) { @@ -115,7 +115,7 @@ func TestCDNSource_Download_Bad(t *testing.T) { }) } -func TestCDNSource_LatestVersion_Bad_NoManifest(t *testing.T) { +func TestCDNSource_LatestVersionNoManifest_Bad(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) @@ -131,7 +131,7 @@ func TestCDNSource_LatestVersion_Bad_NoManifest(t *testing.T) { assert.Equal(t, "latest", version) } -func TestCDNSource_LatestVersion_Bad_ServerError(t *testing.T) { +func TestCDNSource_LatestVersionServerError_Bad(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) @@ -147,12 +147,12 @@ func TestCDNSource_LatestVersion_Bad_ServerError(t *testing.T) { assert.Equal(t, "latest", version) } -func TestCDNSource_Download_Good_NoProgress(t *testing.T) { +func TestCDNSource_DownloadNoProgress_Good(t *testing.T) { content := "test content" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) + w.Header().Set("Content-Length", core.Sprintf("%d", len(content))) w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, content) + _, _ = goio.WriteString(w, content) })) defer server.Close() @@ -166,12 +166,12 @@ func TestCDNSource_Download_Good_NoProgress(t *testing.T) { err := src.Download(context.Background(), io.Local, dest, nil) assert.NoError(t, err) - data, err := os.ReadFile(filepath.Join(dest, "test.img")) + data, err := io.Local.Read(coreutil.JoinPath(dest, "test.img")) assert.NoError(t, err) - assert.Equal(t, content, string(data)) + assert.Equal(t, content, data) } -func TestCDNSource_Download_Good_LargeFile(t *testing.T) { +func TestCDNSource_DownloadLargeFile_Good(t *testing.T) { // Create content larger than buffer size (32KB) content := make([]byte, 64*1024) // 64KB for i := range content { @@ -179,7 +179,7 @@ func TestCDNSource_Download_Good_LargeFile(t *testing.T) { } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) + w.Header().Set("Content-Length", core.Sprintf("%d", len(content))) w.WriteHeader(http.StatusOK) _, _ = w.Write(content) })) @@ -203,7 +203,7 @@ func TestCDNSource_Download_Good_LargeFile(t *testing.T) { assert.Equal(t, int64(len(content)), lastDownloaded) } -func TestCDNSource_Download_Bad_HTTPErrorCodes(t *testing.T) { +func TestCDNSource_DownloadHTTPErrorCodes_Bad(t *testing.T) { testCases := []struct { name string statusCode int @@ -230,17 +230,17 @@ func TestCDNSource_Download_Bad_HTTPErrorCodes(t *testing.T) { err := src.Download(context.Background(), io.Local, dest, nil) assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", tc.statusCode)) + assert.Contains(t, err.Error(), core.Sprintf("HTTP %d", tc.statusCode)) }) } } -func TestCDNSource_InterfaceCompliance(t *testing.T) { +func TestCDNSource_InterfaceCompliance_Good(t *testing.T) { // Verify CDNSource implements ImageSource var _ ImageSource = (*CDNSource)(nil) } -func TestCDNSource_Config(t *testing.T) { +func TestCDNSource_Config_Good(t *testing.T) { cfg := SourceConfig{ CDNURL: "https://cdn.example.com", ImageName: "my-image.qcow2", @@ -251,7 +251,7 @@ func TestCDNSource_Config(t *testing.T) { assert.Equal(t, "my-image.qcow2", src.config.ImageName) } -func TestNewCDNSource_Good(t *testing.T) { +func TestCDN_NewCDNSource_Good(t *testing.T) { cfg := SourceConfig{ GitHubRepo: "host-uk/core-images", RegistryImage: "ghcr.io/host-uk/core-devops", @@ -265,16 +265,16 @@ func TestNewCDNSource_Good(t *testing.T) { assert.Equal(t, cfg.CDNURL, src.config.CDNURL) } -func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) { +func TestCDNSource_DownloadCreatesDestDir_Good(t *testing.T) { content := "test content" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, content) + _, _ = goio.WriteString(w, content) })) defer server.Close() tmpDir := t.TempDir() - dest := filepath.Join(tmpDir, "nested", "dir") + dest := coreutil.JoinPath(tmpDir, "nested", "dir") // dest doesn't exist yet src := NewCDNSource(SourceConfig{ @@ -286,12 +286,12 @@ func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) { assert.NoError(t, err) // Verify nested dir was created - info, err := os.Stat(dest) + info, err := io.Local.Stat(dest) assert.NoError(t, err) assert.True(t, info.IsDir()) } -func TestSourceConfig_Struct(t *testing.T) { +func TestSourceConfig_Struct_Good(t *testing.T) { cfg := SourceConfig{ GitHubRepo: "owner/repo", RegistryImage: "ghcr.io/owner/image", diff --git a/sources/github.go b/sources/github.go index 2b03e90..e48837b 100644 --- a/sources/github.go +++ b/sources/github.go @@ -2,12 +2,12 @@ package sources import ( "context" - "os" - "os/exec" - "strings" - "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/proc" ) // GitHubSource downloads images from GitHub Releases. @@ -19,6 +19,10 @@ type GitHubSource struct { var _ ImageSource = (*GitHubSource)(nil) // NewGitHubSource creates a new GitHub source. +// +// Usage: +// +// src := NewGitHubSource(cfg) func NewGitHubSource(cfg SourceConfig) *GitHubSource { return &GitHubSource{config: cfg} } @@ -30,18 +34,18 @@ func (s *GitHubSource) Name() string { // Available checks if gh CLI is installed and authenticated. func (s *GitHubSource) Available() bool { - _, err := exec.LookPath("gh") + _, err := proc.LookPath("gh") if err != nil { return false } // Check if authenticated - cmd := exec.Command("gh", "auth", "status") + cmd := proc.NewCommand("gh", "auth", "status") return cmd.Run() == nil } // LatestVersion returns the latest release tag. func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, "gh", "release", "view", + cmd := proc.NewCommandContext(ctx, "gh", "release", "view", "-R", s.config.GitHubRepo, "--json", "tagName", "-q", ".tagName", @@ -50,20 +54,20 @@ func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) { if err != nil { return "", coreerr.E("github.LatestVersion", "failed", err) } - return strings.TrimSpace(string(out)), nil + return core.Trim(string(out)), nil } // Download downloads the image from the latest release. 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 - cmd := exec.CommandContext(ctx, "gh", "release", "download", + cmd := proc.NewCommandContext(ctx, "gh", "release", "download", "-R", s.config.GitHubRepo, "-p", s.config.ImageName, "-D", dest, "--clobber", ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stdout = proc.Stdout + cmd.Stderr = proc.Stderr if err := cmd.Run(); err != nil { return coreerr.E("github.Download", "failed", err) diff --git a/sources/github_test.go b/sources/github_test.go index 7281129..577cc48 100644 --- a/sources/github_test.go +++ b/sources/github_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGitHubSource_Good_Available(t *testing.T) { +func TestGitHubSource_Available_Good(t *testing.T) { src := NewGitHubSource(SourceConfig{ GitHubRepo: "host-uk/core-images", ImageName: "core-devops-darwin-arm64.qcow2", @@ -20,12 +20,12 @@ func TestGitHubSource_Good_Available(t *testing.T) { _ = src.Available() } -func TestGitHubSource_Name(t *testing.T) { +func TestGitHubSource_Name_Good(t *testing.T) { src := NewGitHubSource(SourceConfig{}) assert.Equal(t, "github", src.Name()) } -func TestGitHubSource_Config(t *testing.T) { +func TestGitHubSource_Config_Good(t *testing.T) { cfg := SourceConfig{ GitHubRepo: "owner/repo", ImageName: "test-image.qcow2", @@ -37,7 +37,7 @@ func TestGitHubSource_Config(t *testing.T) { assert.Equal(t, "test-image.qcow2", src.config.ImageName) } -func TestGitHubSource_Good_Multiple(t *testing.T) { +func TestGitHubSource_Multiple_Good(t *testing.T) { // Test creating multiple sources with different configs src1 := NewGitHubSource(SourceConfig{GitHubRepo: "org1/repo1", ImageName: "img1.qcow2"}) src2 := NewGitHubSource(SourceConfig{GitHubRepo: "org2/repo2", ImageName: "img2.qcow2"}) @@ -48,7 +48,7 @@ func TestGitHubSource_Good_Multiple(t *testing.T) { assert.Equal(t, "github", src2.Name()) } -func TestNewGitHubSource_Good(t *testing.T) { +func TestGitHub_NewGitHubSource_Good(t *testing.T) { cfg := SourceConfig{ GitHubRepo: "host-uk/core-images", RegistryImage: "ghcr.io/host-uk/core-devops", @@ -62,7 +62,7 @@ func TestNewGitHubSource_Good(t *testing.T) { assert.Equal(t, cfg.GitHubRepo, src.config.GitHubRepo) } -func TestGitHubSource_InterfaceCompliance(t *testing.T) { +func TestGitHubSource_InterfaceCompliance_Good(t *testing.T) { // Verify GitHubSource implements ImageSource var _ ImageSource = (*GitHubSource)(nil) } diff --git a/sources/source.go b/sources/source.go index c03f026..1d86684 100644 --- a/sources/source.go +++ b/sources/source.go @@ -1,10 +1,10 @@ -// Package sources provides image download sources for go-container. +// Package sources provides image download sources for container. package sources import ( "context" - "forge.lthn.ai/core/go-io" + "dappco.re/go/core/io" ) // ImageSource defines the interface for downloading dev images. diff --git a/sources/source_test.go b/sources/source_test.go index a63f09b..f1538a5 100644 --- a/sources/source_test.go +++ b/sources/source_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestSourceConfig_Empty(t *testing.T) { +func TestSourceConfig_Empty_Good(t *testing.T) { cfg := SourceConfig{} assert.Empty(t, cfg.GitHubRepo) assert.Empty(t, cfg.RegistryImage) @@ -14,7 +14,7 @@ func TestSourceConfig_Empty(t *testing.T) { assert.Empty(t, cfg.ImageName) } -func TestSourceConfig_Complete(t *testing.T) { +func TestSourceConfig_Complete_Good(t *testing.T) { cfg := SourceConfig{ GitHubRepo: "owner/repo", RegistryImage: "ghcr.io/owner/image:v1", @@ -28,7 +28,7 @@ func TestSourceConfig_Complete(t *testing.T) { assert.Equal(t, "my-image-darwin-arm64.qcow2", cfg.ImageName) } -func TestImageSource_Interface(t *testing.T) { +func TestImageSource_Interface_Good(t *testing.T) { // Ensure both sources implement the interface var _ ImageSource = (*GitHubSource)(nil) var _ ImageSource = (*CDNSource)(nil) diff --git a/specs/cmd/vm.md b/specs/cmd/vm.md new file mode 100644 index 0000000..5bfa92a --- /dev/null +++ b/specs/cmd/vm.md @@ -0,0 +1,12 @@ +# vm +**Import:** `dappco.re/go/core/container/cmd/vm` +**Files:** 4 + +## Types + +## Functions + +### Exported Functions +- `AddVMCommands(root *cli.Command)`: Registers the `vm` command tree and its `run`, `ps`, `stop`, `logs`, `exec`, and `templates` subcommands on the parent CLI. +- `ParseVarFlags(varFlags []string) map[string]string`: Parses repeated `KEY=VALUE` `--var` flags, trims whitespace, and strips matching single or double quotes around values. +- `RunFromTemplate(templateName string, vars map[string]string, runOpts container.RunOptions) error`: Applies a named LinuxKit template, writes it into a temporary build directory, runs `linuxkit build --format iso-bios`, locates the resulting image file, and starts it through `container.NewLinuxKitManager`. diff --git a/specs/container.md b/specs/container.md new file mode 100644 index 0000000..c751de8 --- /dev/null +++ b/specs/container.md @@ -0,0 +1,175 @@ +# container +**Import:** `dappco.re/go/core/container` +**Files:** 5 + +## Types + +### `Container` +`type Container struct` + +Represents the runtime and persisted record for a LinuxKit VM. + +- `ID string`: An 8-character hex identifier generated by `GenerateID`. +- `Name string`: Optional human-readable container name. +- `Image string`: Path to the LinuxKit image file used to start the VM. +- `Status Status`: Current lifecycle state. +- `PID int`: Hypervisor process ID. +- `StartedAt time.Time`: VM start time. +- `Ports map[int]int`: Host-to-guest port mappings. +- `Memory int`: Memory allocation in MB. +- `CPUs int`: CPU allocation count. + +### `Status` +`type Status string` + +Container lifecycle state values. + +- `StatusRunning`: The VM is running. +- `StatusStopped`: The VM has stopped cleanly or was marked stopped after exit. +- `StatusError`: The VM exited or was tracked with an error state. + +### `RunOptions` +`type RunOptions struct` + +Options passed to `Manager.Run` and `LinuxKitManager.Run`. + +- `Name string`: Optional human-readable name. +- `Detach bool`: Starts the VM in the background when true. +- `Memory int`: Requested memory in MB. `LinuxKitManager.Run` defaults this to `1024` when unset. +- `CPUs int`: Requested CPU count. `LinuxKitManager.Run` defaults this to `1` when unset. +- `Ports map[int]int`: Host-to-guest port forwards. +- `Volumes map[string]string`: Host-to-guest 9p share mappings. +- `SSHPort int`: Host port forwarded to guest SSH. `LinuxKitManager.Run` defaults this to `2222` when unset. +- `SSHKey string`: Declared SSH private key path for exec operations. + +### `Manager` +`type Manager interface` + +Abstracts container lifecycle operations for LinuxKit VMs. + +- `Run(ctx, image, opts)`: Starts a VM from an image and returns its record. +- `Stop(ctx, id)`: Stops a VM by container ID. +- `List(ctx)`: Returns known containers. +- `Logs(ctx, id, follow)`: Opens the container log stream, optionally in follow mode. +- `Exec(ctx, id, cmd)`: Runs a command inside the guest through SSH. + +### `Hypervisor` +`type Hypervisor interface` + +Builds the process invocation used to boot a LinuxKit image. + +- `Name() string`: Returns the hypervisor identifier. +- `Available() bool`: Reports whether the implementation can run on the current system. +- `BuildCommand(ctx, image, opts)`: Produces the `proc.Command` used to launch the guest. + +### `HypervisorOptions` +`type HypervisorOptions struct` + +Normalized VM launch settings handed to a `Hypervisor`. + +- `Memory int`: Guest memory in MB. +- `CPUs int`: Guest CPU count. +- `LogFile string`: Log file path selected by the manager. +- `SSHPort int`: Host SSH forward. +- `Ports map[int]int`: Additional host-to-guest port forwards. +- `Volumes map[string]string`: Host-to-guest volume mappings. +- `Detach bool`: Background execution flag. + +### `QemuHypervisor` +`type QemuHypervisor struct` + +`Hypervisor` implementation for QEMU. + +- `Binary string`: Executable to run. The constructor defaults this to `qemu-system-x86_64`. + +### `HyperkitHypervisor` +`type HyperkitHypervisor struct` + +`Hypervisor` implementation for Hyperkit on macOS. + +- `Binary string`: Executable to run. The constructor defaults this to `hyperkit`. + +### `ImageFormat` +`type ImageFormat string` + +Disk or ISO format detected from an image filename. + +- `FormatISO`: `.iso` +- `FormatQCOW2`: `.qcow2` +- `FormatVMDK`: `.vmdk` +- `FormatRaw`: `.raw` or `.img` +- `FormatUnknown`: Any other extension + +### `LinuxKitManager` +`type LinuxKitManager struct` + +Concrete `Manager` backed by a `State`, a detected or injected `Hypervisor`, and an `io.Medium`. + +### `State` +`type State struct` + +Persistent container registry stored as JSON. + +- `Containers map[string]*Container`: Container records keyed by ID. + +### `Template` +`type Template struct` + +Metadata for an embedded or user-supplied LinuxKit YAML template. + +- `Name string`: Template identifier such as `core-dev`. +- `Description string`: Human-readable description shown in template listings. +- `Path string`: Embedded path or on-disk path to the YAML source. + +## Functions + +### Top-Level Functions +- `GenerateID() (string, error)`: Reads 4 random bytes and returns an 8-character hex ID. +- `DetectImageFormat(path string) ImageFormat`: Maps file extensions to `ImageFormat` values. +- `DetectHypervisor() (Hypervisor, error)`: Prefers Hyperkit on macOS when available, otherwise tries QEMU, and errors when neither is usable. +- `GetHypervisor(name string) (Hypervisor, error)`: Returns a named hypervisor only if the requested implementation is currently available. +- `DefaultStateDir() (string, error)`: Resolves the state directory to `~/.core`. +- `DefaultStatePath() (string, error)`: Resolves the JSON state file path to `~/.core/containers.json`. +- `DefaultLogsDir() (string, error)`: Resolves the log directory to `~/.core/logs`. +- `LogPath(id string) (string, error)`: Resolves a container log path as `/.log`. +- `EnsureLogsDir() error`: Creates the log directory if it does not already exist. +- `NewState(filePath string) *State`: Returns an empty in-memory state bound to a file path. +- `LoadState(filePath string) (*State, error)`: Loads JSON state from disk or returns an empty state when the file does not exist. +- `ListTemplates() []Template`: Collects embedded templates first, then user templates from `.core/linuxkit`. +- `ListTemplatesIter() iter.Seq[Template]`: Streams the same template list lazily. +- `GetTemplate(name string) (string, error)`: Reads an embedded template first, then a user template file named `.yml` from the user template directory. +- `ApplyTemplate(name string, vars map[string]string) (string, error)`: Loads a named template and applies variable substitution. +- `ApplyVariables(content string, vars map[string]string) (string, error)`: Replaces `${VAR}` and `${VAR:-default}` placeholders and errors when required variables are missing. +- `ExtractVariables(content string) (required []string, optional map[string]string)`: Returns sorted required variable names and optional variables with their defaults. + +### `QemuHypervisor` +- `NewQemuHypervisor() *QemuHypervisor`: Returns a `QemuHypervisor` configured with the default QEMU binary name. +- `(*QemuHypervisor).Name() string`: Returns `qemu`. +- `(*QemuHypervisor).Available() bool`: Checks for the configured binary on `PATH`. +- `(*QemuHypervisor).BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error)`: Builds a QEMU command for ISO, qcow2, vmdk, or raw images, enables nographic serial output, applies user-mode networking and host port forwards, mounts 9p shares, and uses KVM or HVF acceleration when available. + +### `HyperkitHypervisor` +- `NewHyperkitHypervisor() *HyperkitHypervisor`: Returns a `HyperkitHypervisor` configured with the default Hyperkit binary name. +- `(*HyperkitHypervisor).Name() string`: Returns `hyperkit`. +- `(*HyperkitHypervisor).Available() bool`: Requires macOS and a `hyperkit` binary on `PATH`. +- `(*HyperkitHypervisor).BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error)`: Builds a Hyperkit command with ACPI, serial console output, disk attachment for ISO or block images, and optional slirp TCP forwards for SSH and declared ports. + +### `LinuxKitManager` +- `NewLinuxKitManager(m io.Medium) (*LinuxKitManager, error)`: Resolves the default state path, loads state, auto-detects a hypervisor, and returns a ready manager. +- `NewLinuxKitManagerWithHypervisor(m io.Medium, state *State, hypervisor Hypervisor) *LinuxKitManager`: Creates a manager around injected dependencies without auto-detection. +- `(*LinuxKitManager).Run(ctx context.Context, image string, opts RunOptions) (*Container, error)`: Validates the image, applies defaults, creates a log file, starts the hypervisor, records the container in state, and updates status after foreground completion or detached exit. +- `(*LinuxKitManager).Stop(ctx context.Context, id string) error`: Sends `SIGTERM` to a running VM, escalates to `SIGKILL` after 10 seconds or on context cancellation, and persists the stopped state. +- `(*LinuxKitManager).List(ctx context.Context) ([]*Container, error)`: Returns copies of all tracked containers and reconciles stale running entries when their process is no longer alive. +- `(*LinuxKitManager).Logs(ctx context.Context, id string, follow bool) (io.ReadCloser, error)`: Opens the log file directly or returns a follow reader that tails new data until the context ends. +- `(*LinuxKitManager).Exec(ctx context.Context, id string, cmd []string) error`: Runs `ssh` to `root@localhost` on port `2222` with strict host-key checking and the shared `~/.core/known_hosts` file. +- `(*LinuxKitManager).State() *State`: Returns the manager's state object. +- `(*LinuxKitManager).Hypervisor() Hypervisor`: Returns the manager's configured hypervisor. + +### `State` +- `(*State).SaveState() error`: Serializes the state as JSON and writes it to the configured file, creating parent directories first. +- `(*State).Add(c *Container) error`: Inserts a container record and persists the state. +- `(*State).Get(id string) (*Container, bool)`: Returns a copy of the stored container to avoid caller-side data races. +- `(*State).Update(c *Container) error`: Replaces a container record and persists the state. +- `(*State).Remove(id string) error`: Deletes a container record and persists the state. +- `(*State).All() []*Container`: Returns copies of every stored container. +- `(*State).FilePath() string`: Returns the JSON file path used for persistence. diff --git a/specs/devenv.md b/specs/devenv.md new file mode 100644 index 0000000..49589e4 --- /dev/null +++ b/specs/devenv.md @@ -0,0 +1,184 @@ +# devenv +**Import:** `dappco.re/go/core/container/devenv` +**Files:** 8 + +## Types + +### `BootOptions` +`type BootOptions struct` + +Startup settings for the development VM. + +- `Memory int`: Guest memory in MB. `DefaultBootOptions` sets this to `4096`. +- `CPUs int`: Guest CPU count. `DefaultBootOptions` sets this to `2`. +- `Name string`: Container name. `DefaultBootOptions` sets this to `core-dev`. +- `Fresh bool`: Stops any existing environment before booting a new one. + +### `CDNConfig` +`type CDNConfig struct` + +CDN download configuration. + +- `URL string`: Base URL used by the CDN image source. + +### `ClaudeOptions` +`type ClaudeOptions struct` + +Controls how `DevOps.Claude` launches a sandboxed Claude session inside the VM. + +- `NoAuth bool`: Disables extra auth forwarding. +- `Auth []string`: Requested auth categories. Recognized values in the current implementation are `gh`, `anthropic`, `ssh`, and `git`. +- `Model string`: Optional model flag appended to the `claude` command. + +### `Config` +`type Config struct` + +Top-level configuration loaded from `~/.core/config.yaml`. + +- `Version int`: Config schema version. +- `Images ImagesConfig`: Image source settings. + +### `DevOps` +`type DevOps struct` + +High-level façade that composes configuration loading, image management, LinuxKit VM lifecycle, SSH access, project mounting, and helper workflows. + +### `DevStatus` +`type DevStatus struct` + +Snapshot returned by `DevOps.Status`. + +- `Installed bool`: Whether the platform image file is present locally. +- `Running bool`: Whether the tracked dev container is currently running. +- `ImageVersion string`: Installed image version from the manifest, when known. +- `ContainerID string`: Current container ID, when present. +- `Memory int`: Memory allocation recorded on the container. +- `CPUs int`: CPU allocation recorded on the container. +- `SSHPort int`: SSH port reported by status. The current implementation always returns `DefaultSSHPort`. +- `Uptime time.Duration`: Time since the container started when it is running. + +### `GitHubConfig` +`type GitHubConfig struct` + +GitHub Releases source configuration. + +- `Repo string`: Repository in `owner/repo` form. + +### `ImageInfo` +`type ImageInfo struct` + +Manifest entry for an installed image. + +- `Version string`: Installed version string. +- `SHA256 string`: Optional checksum field stored in the manifest. +- `Downloaded time.Time`: Download time recorded at install. +- `Source string`: Source identifier such as `github` or `cdn`. + +### `ImageManager` +`type ImageManager struct` + +Image installer and update checker backed by a manifest and an ordered list of `sources.ImageSource` implementations. + +### `ImagesConfig` +`type ImagesConfig struct` + +Image source selection and per-source configuration. + +- `Source string`: Source mode. The code handles `auto`, `github`, and `cdn`. +- `GitHub GitHubConfig`: GitHub Releases settings. +- `Registry RegistryConfig`: Registry settings kept in configuration but not consumed by the current source builder. +- `CDN CDNConfig`: CDN settings. + +### `Manifest` +`type Manifest struct` + +On-disk JSON manifest for installed images. + +- `Images map[string]ImageInfo`: Installed images keyed by image filename. + +### `RegistryConfig` +`type RegistryConfig struct` + +Container registry configuration. + +- `Image string`: Registry image reference. + +### `ServeOptions` +`type ServeOptions struct` + +Options for `DevOps.Serve`. + +- `Port int`: Display port. The implementation defaults this to `8000`. +- `Path string`: Optional subdirectory below `projectDir` to mount and serve. + +### `ShellOptions` +`type ShellOptions struct` + +Options for `DevOps.Shell`. + +- `Console bool`: Uses the serial console socket instead of SSH. +- `Command []string`: Remote command to run. An empty slice opens an interactive SSH shell. + +### `TestCommand` +`type TestCommand struct` + +Named command entry from `.core/test.yaml`. + +- `Name string`: Command selector. +- `Run string`: Shell command to execute. + +### `TestConfig` +`type TestConfig struct` + +Project-local test configuration loaded from `.core/test.yaml`. + +- `Version int`: Config version. +- `Command string`: Default test command. +- `Commands []TestCommand`: Named commands that can be selected by `TestOptions.Name`. +- `Env map[string]string`: Environment entries parsed from the YAML file. + +### `TestOptions` +`type TestOptions struct` + +Controls how `DevOps.Test` chooses a command. + +- `Name string`: Named command from `.core/test.yaml`. +- `Command []string`: Explicit command override supplied as arguments. + +## Functions + +### Top-Level Functions +- `ConfigPath() (string, error)`: Resolves the global config path to `~/.core/config.yaml`. +- `DefaultConfig() *Config`: Returns version `1` config with `auto` image selection, GitHub repo `host-uk/core-images`, and registry image `ghcr.io/host-uk/core-devops`. +- `LoadConfig(m io.Medium) (*Config, error)`: Loads the global config file with the shared config service and falls back to defaults when the file is absent or the home directory cannot be resolved. +- `ImageName() string`: Builds the platform image filename as `core-devops--.qcow2`. +- `ImagesDir() (string, error)`: Uses `CORE_IMAGES_DIR` when set, otherwise resolves `~/.core/images`. +- `ImagePath() (string, error)`: Joins `ImagesDir` and `ImageName`. +- `DefaultBootOptions() BootOptions`: Returns `Memory: 4096`, `CPUs: 2`, and `Name: core-dev`. +- `DetectServeCommand(m io.Medium, projectDir string) string`: Chooses a serve command by checking for Laravel, Node.js, Composer PHP, Go, Django, and then falling back to `python3 -m http.server 8000`. +- `DetectTestCommand(m io.Medium, projectDir string) string`: Chooses a test command from `.core/test.yaml`, Composer, npm, Go, pytest, or Taskfile conventions. +- `LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error)`: Reads and unmarshals `.core/test.yaml` from the project directory. +- `New(m io.Medium) (*DevOps, error)`: Loads configuration, creates an `ImageManager`, creates a `container.LinuxKitManager`, and returns a ready `DevOps` instance. + +### `DevOps` +- `(*DevOps).IsInstalled() bool`: Checks whether the platform image file exists on the configured medium. +- `(*DevOps).Install(ctx context.Context, progress func(downloaded, total int64)) error`: Downloads and records the dev image through the embedded `ImageManager`. +- `(*DevOps).CheckUpdate(ctx context.Context) (current, latest string, hasUpdate bool, err error)`: Delegates update discovery to the `ImageManager`. +- `(*DevOps).Boot(ctx context.Context, opts BootOptions) error`: Verifies that the image is installed, optionally stops an existing environment, starts a detached LinuxKit VM on `DefaultSSHPort`, and retries SSH host-key scanning for up to 60 seconds. +- `(*DevOps).Stop(ctx context.Context) error`: Finds the `core-dev` container and stops it through the container manager. +- `(*DevOps).IsRunning(ctx context.Context) (bool, error)`: Reports whether the `core-dev` container exists and is marked running. +- `(*DevOps).Status(ctx context.Context) (*DevStatus, error)`: Combines manifest information and the current container record into a status snapshot. +- `(*DevOps).Shell(ctx context.Context, opts ShellOptions) error`: Opens either an SSH shell or a serial-console connection, failing if the environment is not running. +- `(*DevOps).Serve(ctx context.Context, projectDir string, opts ServeOptions) error`: Mounts the requested project path into `/app`, auto-detects a serve command, and runs it over SSH. +- `(*DevOps).Test(ctx context.Context, projectDir string, opts TestOptions) error`: Chooses a test command from explicit arguments, a named config entry, or auto-detection, then executes it over SSH from `/app`. +- `(*DevOps).Claude(ctx context.Context, projectDir string, opts ClaudeOptions) error`: Auto-boots when needed, mounts the project, prepares forwarded auth-related environment variables, and runs `claude` inside the VM over SSH agent forwarding. +- `(*DevOps).CopyGHAuth(ctx context.Context) error`: Copies the host `~/.config/gh` directory into `/root/.config/` inside the VM with `scp` when the host config exists. + +### `ImageManager` +- `NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error)`: Ensures the images directory exists, loads or creates the manifest, and builds the ordered image-source list from the current config. +- `(*ImageManager).IsInstalled() bool`: Checks whether the current platform image file exists. +- `(*ImageManager).Install(ctx context.Context, progress func(downloaded, total int64)) error`: Picks the first available configured source, downloads the current platform image, records its version and source, and saves the manifest. +- `(*ImageManager).CheckUpdate(ctx context.Context) (current, latest string, hasUpdate bool, err error)`: Reads the current version from the manifest, asks the first available source for the latest version, and compares them. + +### `Manifest` +- `(*Manifest).Save() error`: Serializes the manifest as JSON and writes it to the bound manifest path. diff --git a/specs/internal/coreutil.md b/specs/internal/coreutil.md new file mode 100644 index 0000000..7c7bc6c --- /dev/null +++ b/specs/internal/coreutil.md @@ -0,0 +1,16 @@ +# coreutil +**Import:** `dappco.re/go/core/container/internal/coreutil` +**Files:** 1 + +## Types + +## Functions + +### Exported Functions +- `DirSep() string`: Returns the active directory separator, preferring the `DS` environment variable and otherwise `/`. +- `JoinPath(parts ...string) string`: Joins path elements with `DirSep()` and normalizes the result with `core.CleanPath`. +- `HomeDir() string`: Returns the home directory by checking `CORE_HOME`, `HOME`, `USERPROFILE`, and then `DIR_HOME`. +- `CurrentDir() string`: Returns the current working directory from `PWD`, or `DIR_CWD` when `PWD` is unset. +- `TempDir() string`: Returns the temporary directory from `TMPDIR`, or `DIR_TMP` when `TMPDIR` is unset. +- `AbsPath(path string) string`: Returns `CurrentDir()` for an empty string, preserves absolute paths, and otherwise resolves relative paths against the current directory. +- `MkdirTemp(prefix string) (string, error)`: Creates a deterministic temporary directory inside `TempDir()` using the supplied prefix or `tmp-` when no prefix is provided. diff --git a/specs/internal/proc.md b/specs/internal/proc.md new file mode 100644 index 0000000..b469e3e --- /dev/null +++ b/specs/internal/proc.md @@ -0,0 +1,46 @@ +# proc +**Import:** `dappco.re/go/core/container/internal/proc` +**Files:** 1 + +## Types + +### `Process` +`type Process struct` + +Lightweight wrapper around an OS process ID. + +- `Pid int`: Operating-system process ID. + +### `Command` +`type Command struct` + +Minimal process runner built directly on `syscall.StartProcess`. + +- `Path string`: Executable name or path to start. +- `Args []string`: Full argument vector passed to the child process, including the command name at index 0. +- `Dir string`: Working directory for the child process. +- `Env []string`: Child environment. When nil, `Start` uses `Environ()`. +- `Stdin io.Reader`: Reader wired to child stdin when it exposes a file descriptor. +- `Stdout io.Writer`: Writer wired to child stdout when it exposes a file descriptor. +- `Stderr io.Writer`: Writer wired to child stderr when it exposes a file descriptor. +- `Process *Process`: Populated after `Start` succeeds. + +## Functions + +### Top-Level Functions +- `Environ() []string`: Returns the current process environment from `syscall.Environ`. +- `NewCommandContext(ctx context.Context, name string, args ...string) *Command`: Constructs a command with the supplied context and an argument vector beginning with `name`. +- `NewCommand(name string, args ...string) *Command`: Shorthand for `NewCommandContext(context.Background(), name, args...)`. +- `LookPath(name string) (string, error)`: Resolves an executable path from `PATH` using `PS` as the path separator when set, or validates a direct path when `name` already contains a slash or backslash. + +### `Process` +- `(*Process).Kill() error`: Sends `SIGKILL` to the process when `Pid` is valid, and otherwise does nothing. +- `(*Process).Signal(sig syscall.Signal) error`: Sends the requested signal when `Pid` is valid, and otherwise does nothing. + +### `Command` +- `(*Command).StdoutPipe() (io.ReadCloser, error)`: Creates a stdout pipe that must be requested before `Start`. +- `(*Command).StderrPipe() (io.ReadCloser, error)`: Creates a stderr pipe that must be requested before `Start`. +- `(*Command).Start() error`: Resolves the executable, maps stdin/stdout/stderr to real file descriptors or `/dev/null`, starts the child with `syscall.StartProcess`, closes child pipe ends in the parent, and arranges for context cancellation to kill the process. +- `(*Command).Run() error`: Calls `Start` and then `Wait`. +- `(*Command).Output() ([]byte, error)`: Captures stdout through `StdoutPipe`, starts the process, reads all output, waits for exit, and returns any read or process error. +- `(*Command).Wait() error`: Waits on the child process with `syscall.Wait4`, caches the result, and returns an error for non-zero exit status or terminating signals. diff --git a/specs/sources.md b/specs/sources.md new file mode 100644 index 0000000..0957cbd --- /dev/null +++ b/specs/sources.md @@ -0,0 +1,51 @@ +# sources +**Import:** `dappco.re/go/core/container/sources` +**Files:** 3 + +## Types + +### `ImageSource` +`type ImageSource interface` + +Download source abstraction used by `devenv.ImageManager`. + +- `Name() string`: Returns the source identifier. +- `Available() bool`: Reports whether the source can be used in the current environment. +- `LatestVersion(ctx)`: Returns the latest available version string. +- `Download(ctx, m, dest, progress)`: Downloads the image into `dest` with the provided medium and optional progress callback. + +### `SourceConfig` +`type SourceConfig struct` + +Shared configuration passed to concrete image sources. + +- `GitHubRepo string`: GitHub repository in `owner/repo` form. +- `RegistryImage string`: Registry image reference reserved in the config shape. +- `CDNURL string`: CDN or object storage base URL. +- `ImageName string`: Expected asset filename. + +### `CDNSource` +`type CDNSource struct` + +`ImageSource` implementation backed by HTTP downloads from a CDN or S3-style bucket. + +### `GitHubSource` +`type GitHubSource struct` + +`ImageSource` implementation backed by the GitHub CLI and GitHub Releases. + +## Functions + +### `CDNSource` +- `NewCDNSource(cfg SourceConfig) *CDNSource`: Returns a CDN-backed source with the supplied configuration. +- `(*CDNSource).Name() string`: Returns `cdn`. +- `(*CDNSource).Available() bool`: Reports whether `SourceConfig.CDNURL` is non-empty. +- `(*CDNSource).LatestVersion(ctx context.Context) (string, error)`: Tries to fetch `/manifest.json` and currently returns `latest` whether the manifest fetch succeeds or falls back. +- `(*CDNSource).Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error`: Downloads `/` over HTTP into `dest/`, creating the destination directory first and invoking the progress callback as bytes are written. + +### `GitHubSource` +- `NewGitHubSource(cfg SourceConfig) *GitHubSource`: Returns a GitHub Releases source with the supplied configuration. +- `(*GitHubSource).Name() string`: Returns `github`. +- `(*GitHubSource).Available() bool`: Requires a `gh` binary on `PATH` and a successful `gh auth status`. +- `(*GitHubSource).LatestVersion(ctx context.Context) (string, error)`: Runs `gh release view -R --json tagName -q .tagName` and returns the trimmed tag. +- `(*GitHubSource).Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error`: Runs `gh release download` for the configured asset pattern into the destination directory and ignores the progress callback. diff --git a/state.go b/state.go index 00139b5..2d28023 100644 --- a/state.go +++ b/state.go @@ -1,12 +1,13 @@ package container import ( - "encoding/json" - "os" - "path/filepath" + "io/fs" "sync" - "forge.lthn.ai/core/go-io" + core "dappco.re/go/core" + "dappco.re/go/core/io" + + "dappco.re/go/core/container/internal/coreutil" ) // State manages persistent container state. @@ -19,33 +20,49 @@ type State struct { } // DefaultStateDir returns the default directory for state files (~/.core). +// +// Usage: +// +// dir, err := DefaultStateDir() func DefaultStateDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err + home := coreutil.HomeDir() + if home == "" { + return "", core.E("DefaultStateDir", "home directory not available", nil) } - return filepath.Join(home, ".core"), nil + return coreutil.JoinPath(home, ".core"), nil } // DefaultStatePath returns the default path for the state file. +// +// Usage: +// +// path, err := DefaultStatePath() func DefaultStatePath() (string, error) { dir, err := DefaultStateDir() if err != nil { return "", err } - return filepath.Join(dir, "containers.json"), nil + return coreutil.JoinPath(dir, "containers.json"), nil } // DefaultLogsDir returns the default directory for container logs. +// +// Usage: +// +// dir, err := DefaultLogsDir() func DefaultLogsDir() (string, error) { dir, err := DefaultStateDir() if err != nil { return "", err } - return filepath.Join(dir, "logs"), nil + return coreutil.JoinPath(dir, "logs"), nil } // NewState creates a new State instance. +// +// Usage: +// +// state := NewState("/tmp/containers.json") func NewState(filePath string) *State { return &State{ Containers: make(map[string]*Container), @@ -55,19 +72,24 @@ func NewState(filePath string) *State { // LoadState loads the state from the given file path. // If the file doesn't exist, returns an empty state. +// +// Usage: +// +// state, err := LoadState("/tmp/containers.json") func LoadState(filePath string) (*State, error) { state := NewState(filePath) dataStr, err := io.Local.Read(filePath) if err != nil { - if os.IsNotExist(err) { + if core.Is(err, fs.ErrNotExist) { return state, nil } return nil, err } - if err := json.Unmarshal([]byte(dataStr), state); err != nil { - return nil, err + result := core.JSONUnmarshalString(dataStr, state) + if !result.OK { + return nil, result.Value.(error) } return state, nil @@ -79,17 +101,17 @@ func (s *State) SaveState() error { defer s.mu.RUnlock() // Ensure the directory exists - dir := filepath.Dir(s.filePath) + dir := core.PathDir(s.filePath) if err := io.Local.EnsureDir(dir); err != nil { return err } - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err + result := core.JSONMarshal(s) + if !result.OK { + return result.Value.(error) } - return io.Local.Write(s.filePath, string(data)) + return io.Local.Write(s.filePath, string(result.Value.([]byte))) } // Add adds a container to the state and persists it. @@ -154,15 +176,23 @@ func (s *State) FilePath() string { } // LogPath returns the log file path for a given container ID. +// +// Usage: +// +// path, err := LogPath(containerID) func LogPath(id string) (string, error) { logsDir, err := DefaultLogsDir() if err != nil { return "", err } - return filepath.Join(logsDir, id+".log"), nil + return coreutil.JoinPath(logsDir, core.Concat(id, ".log")), nil } // EnsureLogsDir ensures the logs directory exists. +// +// Usage: +// +// err := EnsureLogsDir() func EnsureLogsDir() error { logsDir, err := DefaultLogsDir() if err != nil { diff --git a/state_test.go b/state_test.go index 68e6a02..821c054 100644 --- a/state_test.go +++ b/state_test.go @@ -1,16 +1,18 @@ package container import ( - "os" - "path/filepath" "testing" "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/require" ) -func TestNewState_Good(t *testing.T) { +func TestState_NewState_Good(t *testing.T) { state := NewState("/tmp/test-state.json") assert.NotNil(t, state) @@ -18,10 +20,10 @@ func TestNewState_Good(t *testing.T) { assert.Equal(t, "/tmp/test-state.json", state.FilePath()) } -func TestLoadState_Good_NewFile(t *testing.T) { +func TestLoadState_NewFile_Good(t *testing.T) { // Test loading from non-existent file tmpDir := t.TempDir() - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") state, err := LoadState(statePath) @@ -30,9 +32,9 @@ func TestLoadState_Good_NewFile(t *testing.T) { assert.Empty(t, state.Containers) } -func TestLoadState_Good_ExistingFile(t *testing.T) { +func TestLoadState_ExistingFile_Good(t *testing.T) { tmpDir := t.TempDir() - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") // Create a state file with data content := `{ @@ -47,7 +49,7 @@ func TestLoadState_Good_ExistingFile(t *testing.T) { } } }` - err := os.WriteFile(statePath, []byte(content), 0644) + err := io.Local.Write(statePath, content) require.NoError(t, err) state, err := LoadState(statePath) @@ -61,12 +63,12 @@ func TestLoadState_Good_ExistingFile(t *testing.T) { assert.Equal(t, StatusRunning, c.Status) } -func TestLoadState_Bad_InvalidJSON(t *testing.T) { +func TestLoadState_InvalidJSON_Bad(t *testing.T) { tmpDir := t.TempDir() - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") // Create invalid JSON - err := os.WriteFile(statePath, []byte("invalid json{"), 0644) + err := io.Local.Write(statePath, "invalid json{") require.NoError(t, err) _, err = LoadState(statePath) @@ -75,7 +77,7 @@ func TestLoadState_Bad_InvalidJSON(t *testing.T) { func TestState_Add_Good(t *testing.T) { tmpDir := t.TempDir() - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") state := NewState(statePath) container := &Container{ @@ -96,13 +98,12 @@ func TestState_Add_Good(t *testing.T) { assert.Equal(t, container.Name, c.Name) // Verify file was created - _, err = os.Stat(statePath) - assert.NoError(t, err) + assert.True(t, io.Local.IsFile(statePath)) } func TestState_Update_Good(t *testing.T) { tmpDir := t.TempDir() - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") state := NewState(statePath) container := &Container{ @@ -124,7 +125,7 @@ func TestState_Update_Good(t *testing.T) { func TestState_Remove_Good(t *testing.T) { tmpDir := t.TempDir() - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") state := NewState(statePath) container := &Container{ @@ -139,7 +140,7 @@ func TestState_Remove_Good(t *testing.T) { assert.False(t, ok) } -func TestState_Get_Bad_NotFound(t *testing.T) { +func TestState_GetNotFound_Bad(t *testing.T) { state := NewState("/tmp/test-state.json") _, ok := state.Get("nonexistent") @@ -148,7 +149,7 @@ func TestState_Get_Bad_NotFound(t *testing.T) { func TestState_All_Good(t *testing.T) { tmpDir := t.TempDir() - statePath := filepath.Join(tmpDir, "containers.json") + statePath := coreutil.JoinPath(tmpDir, "containers.json") state := NewState(statePath) _ = state.Add(&Container{ID: "aaa11111"}) @@ -159,9 +160,9 @@ func TestState_All_Good(t *testing.T) { assert.Len(t, all, 3) } -func TestState_SaveState_Good_CreatesDirectory(t *testing.T) { +func TestState_SaveStateCreatesDirectory_Good(t *testing.T) { tmpDir := t.TempDir() - nestedPath := filepath.Join(tmpDir, "nested", "dir", "containers.json") + nestedPath := coreutil.JoinPath(tmpDir, "nested", "dir", "containers.json") state := NewState(nestedPath) _ = state.Add(&Container{ID: "abc12345"}) @@ -170,45 +171,43 @@ func TestState_SaveState_Good_CreatesDirectory(t *testing.T) { require.NoError(t, err) // Verify directory was created - _, err = os.Stat(filepath.Dir(nestedPath)) - assert.NoError(t, err) + assert.True(t, io.Local.IsDir(core.PathDir(nestedPath))) } -func TestDefaultStateDir_Good(t *testing.T) { +func TestState_DefaultStateDir_Good(t *testing.T) { dir, err := DefaultStateDir() require.NoError(t, err) assert.Contains(t, dir, ".core") } -func TestDefaultStatePath_Good(t *testing.T) { +func TestState_DefaultStatePath_Good(t *testing.T) { path, err := DefaultStatePath() require.NoError(t, err) assert.Contains(t, path, "containers.json") } -func TestDefaultLogsDir_Good(t *testing.T) { +func TestState_DefaultLogsDir_Good(t *testing.T) { dir, err := DefaultLogsDir() require.NoError(t, err) assert.Contains(t, dir, "logs") } -func TestLogPath_Good(t *testing.T) { +func TestState_LogPath_Good(t *testing.T) { path, err := LogPath("abc12345") require.NoError(t, err) assert.Contains(t, path, "abc12345.log") } -func TestEnsureLogsDir_Good(t *testing.T) { +func TestState_EnsureLogsDir_Good(t *testing.T) { // This test creates real directories - skip in CI if needed err := EnsureLogsDir() assert.NoError(t, err) logsDir, _ := DefaultLogsDir() - _, err = os.Stat(logsDir) - assert.NoError(t, err) + assert.True(t, io.Local.IsDir(logsDir)) } -func TestGenerateID_Good(t *testing.T) { +func TestState_GenerateID_Good(t *testing.T) { id1, err := GenerateID() require.NoError(t, err) assert.Len(t, id1, 8) diff --git a/templates.go b/templates.go index 868ba9c..226d52e 100644 --- a/templates.go +++ b/templates.go @@ -4,14 +4,14 @@ import ( "embed" "iter" "maps" - "os" - "path/filepath" "regexp" "slices" - "strings" - "forge.lthn.ai/core/go-io" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" + "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + + "dappco.re/go/core/container/internal/coreutil" ) //go:embed templates/*.yml @@ -44,11 +44,19 @@ var builtinTemplates = []Template{ // ListTemplates returns all available LinuxKit templates. // It combines embedded templates with any templates found in the user's // .core/linuxkit directory. +// +// Usage: +// +// templates := ListTemplates() func ListTemplates() []Template { return slices.Collect(ListTemplatesIter()) } // ListTemplatesIter returns an iterator for all available LinuxKit templates. +// +// Usage: +// +// for template := range ListTemplatesIter() { _ = template } func ListTemplatesIter() iter.Seq[Template] { return func(yield func(Template) bool) { // Yield builtin templates @@ -72,6 +80,10 @@ func ListTemplatesIter() iter.Seq[Template] { // GetTemplate returns the content of a template by name. // It first checks embedded templates, then user templates. +// +// Usage: +// +// content, err := GetTemplate("core-dev") func GetTemplate(name string) (string, error) { // Check embedded templates first for _, t := range builtinTemplates { @@ -87,7 +99,7 @@ func GetTemplate(name string) (string, error) { // Check user templates userTemplatesDir := getUserTemplatesDir() if userTemplatesDir != "" { - templatePath := filepath.Join(userTemplatesDir, name+".yml") + templatePath := coreutil.JoinPath(userTemplatesDir, core.Concat(name, ".yml")) if io.Local.IsFile(templatePath) { content, err := io.Local.Read(templatePath) if err != nil { @@ -104,6 +116,10 @@ func GetTemplate(name string) (string, error) { // It supports two syntaxes: // - ${VAR} - required variable, returns error if not provided // - ${VAR:-default} - variable with default value +// +// Usage: +// +// content, err := ApplyTemplate("core-dev", vars) func ApplyTemplate(name string, vars map[string]string) (string, error) { content, err := GetTemplate(name) if err != nil { @@ -117,6 +133,10 @@ func ApplyTemplate(name string, vars map[string]string) (string, error) { // It supports two syntaxes: // - ${VAR} - required variable, returns error if not provided // - ${VAR:-default} - variable with default value +// +// Usage: +// +// content, err := ApplyVariables(raw, vars) func ApplyVariables(content string, vars map[string]string) (string, error) { // Pattern for ${VAR:-default} syntax defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`) @@ -158,7 +178,7 @@ func ApplyVariables(content string, vars map[string]string) (string, error) { }) if len(missingVars) > 0 { - return "", coreerr.E("ApplyVariables", "missing required variables: "+strings.Join(missingVars, ", "), nil) + return "", coreerr.E("ApplyVariables", core.Concat("missing required variables: ", core.Join(", ", missingVars...)), nil) } return result, nil @@ -166,6 +186,10 @@ func ApplyVariables(content string, vars map[string]string) (string, error) { // ExtractVariables extracts all variable names from a template. // 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) { optional = make(map[string]string) requiredSet := make(map[string]bool) @@ -206,21 +230,18 @@ func ExtractVariables(content string) (required []string, optional map[string]st // Returns empty string if the directory doesn't exist. func getUserTemplatesDir() string { // Try workspace-relative .core/linuxkit first - cwd, err := os.Getwd() - if err == nil { - wsDir := filepath.Join(cwd, ".core", "linuxkit") - if io.Local.IsDir(wsDir) { - return wsDir - } + wsDir := coreutil.JoinPath(coreutil.CurrentDir(), ".core", "linuxkit") + if io.Local.IsDir(wsDir) { + return wsDir } // Try home directory - home, err := os.UserHomeDir() - if err != nil { + home := coreutil.HomeDir() + if home == "" { return "" } - homeDir := filepath.Join(home, ".core", "linuxkit") + homeDir := coreutil.JoinPath(home, ".core", "linuxkit") if io.Local.IsDir(homeDir) { return homeDir } @@ -243,12 +264,12 @@ func scanUserTemplates(dir string) []Template { } name := entry.Name() - if !strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml") { + if !core.HasSuffix(name, ".yml") && !core.HasSuffix(name, ".yaml") { continue } // Extract template name from filename - templateName := strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml") + templateName := core.TrimSuffix(core.TrimSuffix(name, ".yml"), ".yaml") // Skip if this is a builtin template name (embedded takes precedence) isBuiltin := false @@ -263,7 +284,7 @@ func scanUserTemplates(dir string) []Template { } // Read file to extract description from comments - description := extractTemplateDescription(filepath.Join(dir, name)) + description := extractTemplateDescription(coreutil.JoinPath(dir, name)) if description == "" { description = "User-defined template" } @@ -271,7 +292,7 @@ func scanUserTemplates(dir string) []Template { templates = append(templates, Template{ Name: templateName, Description: description, - Path: filepath.Join(dir, name), + Path: coreutil.JoinPath(dir, name), }) } @@ -286,14 +307,14 @@ func extractTemplateDescription(path string) string { return "" } - lines := strings.Split(content, "\n") + lines := core.Split(content, "\n") var descLines []string for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "#") { + trimmed := core.Trim(line) + if core.HasPrefix(trimmed, "#") { // Remove the # and trim - comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) + comment := core.Trim(core.TrimPrefix(trimmed, "#")) if comment != "" { descLines = append(descLines, comment) // Only take the first meaningful comment line as description diff --git a/templates_test.go b/templates_test.go index d01b9c8..05419b1 100644 --- a/templates_test.go +++ b/templates_test.go @@ -1,16 +1,17 @@ package container import ( - "os" - "path/filepath" - "strings" "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/require" ) -func TestListTemplates_Good(t *testing.T) { +func TestTemplates_ListTemplates_Good(t *testing.T) { templates := ListTemplates() // Should have at least the builtin templates @@ -41,7 +42,7 @@ func TestListTemplates_Good(t *testing.T) { assert.True(t, found, "server-php template should exist") } -func TestGetTemplate_Good_CoreDev(t *testing.T) { +func TestGetTemplate_CoreDev_Good(t *testing.T) { content, err := GetTemplate("core-dev") require.NoError(t, err) @@ -52,7 +53,7 @@ func TestGetTemplate_Good_CoreDev(t *testing.T) { assert.Contains(t, content, "services:") } -func TestGetTemplate_Good_ServerPhp(t *testing.T) { +func TestGetTemplate_ServerPhp_Good(t *testing.T) { content, err := GetTemplate("server-php") require.NoError(t, err) @@ -63,14 +64,14 @@ func TestGetTemplate_Good_ServerPhp(t *testing.T) { assert.Contains(t, content, "${DOMAIN:-localhost}") } -func TestGetTemplate_Bad_NotFound(t *testing.T) { +func TestGetTemplate_NotFound_Bad(t *testing.T) { _, err := GetTemplate("nonexistent-template") assert.Error(t, err) assert.Contains(t, err.Error(), "template not found") } -func TestApplyVariables_Good_SimpleSubstitution(t *testing.T) { +func TestApplyVariables_SimpleSubstitution_Good(t *testing.T) { content := "Hello ${NAME}, welcome to ${PLACE}!" vars := map[string]string{ "NAME": "World", @@ -83,7 +84,7 @@ func TestApplyVariables_Good_SimpleSubstitution(t *testing.T) { assert.Equal(t, "Hello World, welcome to Core!", result) } -func TestApplyVariables_Good_WithDefaults(t *testing.T) { +func TestApplyVariables_WithDefaults_Good(t *testing.T) { content := "Memory: ${MEMORY:-1024}MB, CPUs: ${CPUS:-2}" vars := map[string]string{ "MEMORY": "2048", @@ -96,7 +97,7 @@ func TestApplyVariables_Good_WithDefaults(t *testing.T) { assert.Equal(t, "Memory: 2048MB, CPUs: 2", result) } -func TestApplyVariables_Good_AllDefaults(t *testing.T) { +func TestApplyVariables_AllDefaults_Good(t *testing.T) { content := "${HOST:-localhost}:${PORT:-8080}" vars := map[string]string{} // No vars provided @@ -106,7 +107,7 @@ func TestApplyVariables_Good_AllDefaults(t *testing.T) { assert.Equal(t, "localhost:8080", result) } -func TestApplyVariables_Good_MixedSyntax(t *testing.T) { +func TestApplyVariables_MixedSyntax_Good(t *testing.T) { content := ` hostname: ${HOSTNAME:-myhost} ssh_key: ${SSH_KEY} @@ -125,7 +126,7 @@ memory: ${MEMORY:-512} assert.Contains(t, result, "memory: 512") } -func TestApplyVariables_Good_EmptyDefault(t *testing.T) { +func TestApplyVariables_EmptyDefault_Good(t *testing.T) { content := "value: ${OPT:-}" vars := map[string]string{} @@ -135,7 +136,7 @@ func TestApplyVariables_Good_EmptyDefault(t *testing.T) { assert.Equal(t, "value: ", result) } -func TestApplyVariables_Bad_MissingRequired(t *testing.T) { +func TestApplyVariables_MissingRequired_Bad(t *testing.T) { content := "SSH Key: ${SSH_KEY}" vars := map[string]string{} // Missing required SSH_KEY @@ -146,7 +147,7 @@ func TestApplyVariables_Bad_MissingRequired(t *testing.T) { assert.Contains(t, err.Error(), "SSH_KEY") } -func TestApplyVariables_Bad_MultipleMissing(t *testing.T) { +func TestApplyVariables_MultipleMissing_Bad(t *testing.T) { content := "${VAR1} and ${VAR2} and ${VAR3}" vars := map[string]string{ "VAR2": "provided", @@ -158,10 +159,10 @@ func TestApplyVariables_Bad_MultipleMissing(t *testing.T) { assert.Contains(t, err.Error(), "missing required variables") // Should mention both missing vars errStr := err.Error() - assert.True(t, strings.Contains(errStr, "VAR1") || strings.Contains(errStr, "VAR3")) + assert.True(t, core.Contains(errStr, "VAR1") || core.Contains(errStr, "VAR3")) } -func TestApplyTemplate_Good(t *testing.T) { +func TestTemplates_ApplyTemplate_Good(t *testing.T) { vars := map[string]string{ "SSH_KEY": "ssh-rsa AAAA... user@host", } @@ -175,7 +176,7 @@ func TestApplyTemplate_Good(t *testing.T) { assert.Contains(t, result, "core-dev") // HOSTNAME default } -func TestApplyTemplate_Bad_TemplateNotFound(t *testing.T) { +func TestApplyTemplate_TemplateNotFound_Bad(t *testing.T) { vars := map[string]string{ "SSH_KEY": "test", } @@ -186,7 +187,7 @@ func TestApplyTemplate_Bad_TemplateNotFound(t *testing.T) { assert.Contains(t, err.Error(), "template not found") } -func TestApplyTemplate_Bad_MissingVariable(t *testing.T) { +func TestApplyTemplate_MissingVariable_Bad(t *testing.T) { // server-php requires SSH_KEY vars := map[string]string{} // Missing required SSH_KEY @@ -196,7 +197,7 @@ func TestApplyTemplate_Bad_MissingVariable(t *testing.T) { assert.Contains(t, err.Error(), "missing required variables") } -func TestExtractVariables_Good(t *testing.T) { +func TestTemplates_ExtractVariables_Good(t *testing.T) { content := ` hostname: ${HOSTNAME:-myhost} ssh_key: ${SSH_KEY} @@ -218,7 +219,7 @@ api_key: ${API_KEY} assert.Len(t, optional, 3) } -func TestExtractVariables_Good_NoVariables(t *testing.T) { +func TestExtractVariables_NoVariables_Good(t *testing.T) { content := "This has no variables at all" required, optional := ExtractVariables(content) @@ -227,7 +228,7 @@ func TestExtractVariables_Good_NoVariables(t *testing.T) { assert.Empty(t, optional) } -func TestExtractVariables_Good_OnlyDefaults(t *testing.T) { +func TestExtractVariables_OnlyDefaults_Good(t *testing.T) { content := "${A:-default1} ${B:-default2}" required, optional := ExtractVariables(content) @@ -238,7 +239,7 @@ func TestExtractVariables_Good_OnlyDefaults(t *testing.T) { assert.Equal(t, "default2", optional["B"]) } -func TestScanUserTemplates_Good(t *testing.T) { +func TestTemplates_ScanUserTemplates_Good(t *testing.T) { // Create a temporary directory with template files tmpDir := t.TempDir() @@ -248,11 +249,11 @@ func TestScanUserTemplates_Good(t *testing.T) { kernel: image: linuxkit/kernel:6.6 ` - err := os.WriteFile(filepath.Join(tmpDir, "custom.yml"), []byte(templateContent), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "custom.yml"), templateContent) require.NoError(t, err) // Create a non-template file (should be ignored) - err = os.WriteFile(filepath.Join(tmpDir, "readme.txt"), []byte("Not a template"), 0644) + err = io.Local.Write(coreutil.JoinPath(tmpDir, "readme.txt"), "Not a template") require.NoError(t, err) templates := scanUserTemplates(tmpDir) @@ -262,13 +263,13 @@ kernel: assert.Equal(t, "My Custom Template", templates[0].Description) } -func TestScanUserTemplates_Good_MultipleTemplates(t *testing.T) { +func TestScanUserTemplates_MultipleTemplates_Good(t *testing.T) { tmpDir := t.TempDir() // Create multiple template files - err := os.WriteFile(filepath.Join(tmpDir, "web.yml"), []byte("# Web Server\nkernel:"), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "web.yml"), "# Web Server\nkernel:") require.NoError(t, err) - err = os.WriteFile(filepath.Join(tmpDir, "db.yaml"), []byte("# Database Server\nkernel:"), 0644) + err = io.Local.Write(coreutil.JoinPath(tmpDir, "db.yaml"), "# Database Server\nkernel:") require.NoError(t, err) templates := scanUserTemplates(tmpDir) @@ -284,7 +285,7 @@ func TestScanUserTemplates_Good_MultipleTemplates(t *testing.T) { assert.True(t, names["db"]) } -func TestScanUserTemplates_Good_EmptyDirectory(t *testing.T) { +func TestScanUserTemplates_EmptyDirectory_Good(t *testing.T) { tmpDir := t.TempDir() templates := scanUserTemplates(tmpDir) @@ -292,22 +293,22 @@ func TestScanUserTemplates_Good_EmptyDirectory(t *testing.T) { assert.Empty(t, templates) } -func TestScanUserTemplates_Bad_NonexistentDirectory(t *testing.T) { +func TestScanUserTemplates_NonexistentDirectory_Bad(t *testing.T) { templates := scanUserTemplates("/nonexistent/path/to/templates") assert.Empty(t, templates) } -func TestExtractTemplateDescription_Good(t *testing.T) { +func TestTemplates_ExtractTemplateDescription_Good(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "test.yml") + path := coreutil.JoinPath(tmpDir, "test.yml") content := `# My Template Description # More details here kernel: image: test ` - err := os.WriteFile(path, []byte(content), 0644) + err := io.Local.Write(path, content) require.NoError(t, err) desc := extractTemplateDescription(path) @@ -315,14 +316,14 @@ kernel: assert.Equal(t, "My Template Description", desc) } -func TestExtractTemplateDescription_Good_NoComments(t *testing.T) { +func TestExtractTemplateDescription_NoComments_Good(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "test.yml") + path := coreutil.JoinPath(tmpDir, "test.yml") content := `kernel: image: test ` - err := os.WriteFile(path, []byte(content), 0644) + err := io.Local.Write(path, content) require.NoError(t, err) desc := extractTemplateDescription(path) @@ -330,13 +331,13 @@ func TestExtractTemplateDescription_Good_NoComments(t *testing.T) { assert.Empty(t, desc) } -func TestExtractTemplateDescription_Bad_FileNotFound(t *testing.T) { +func TestExtractTemplateDescription_FileNotFound_Bad(t *testing.T) { desc := extractTemplateDescription("/nonexistent/file.yml") assert.Empty(t, desc) } -func TestVariablePatternEdgeCases_Good(t *testing.T) { +func TestTemplates_VariablePatternEdgeCases_Good(t *testing.T) { tests := []struct { name string content string @@ -384,15 +385,15 @@ func TestVariablePatternEdgeCases_Good(t *testing.T) { } } -func TestScanUserTemplates_Good_SkipsBuiltinNames(t *testing.T) { +func TestScanUserTemplates_SkipsBuiltinNames_Good(t *testing.T) { tmpDir := t.TempDir() // Create a template with a builtin name (should be skipped) - err := os.WriteFile(filepath.Join(tmpDir, "core-dev.yml"), []byte("# Duplicate\nkernel:"), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "core-dev.yml"), "# Duplicate\nkernel:") require.NoError(t, err) // Create a unique template - err = os.WriteFile(filepath.Join(tmpDir, "unique.yml"), []byte("# Unique\nkernel:"), 0644) + err = io.Local.Write(coreutil.JoinPath(tmpDir, "unique.yml"), "# Unique\nkernel:") require.NoError(t, err) templates := scanUserTemplates(tmpDir) @@ -402,15 +403,15 @@ func TestScanUserTemplates_Good_SkipsBuiltinNames(t *testing.T) { assert.Equal(t, "unique", templates[0].Name) } -func TestScanUserTemplates_Good_SkipsDirectories(t *testing.T) { +func TestScanUserTemplates_SkipsDirectories_Good(t *testing.T) { tmpDir := t.TempDir() // Create a subdirectory (should be skipped) - err := os.MkdirAll(filepath.Join(tmpDir, "subdir"), 0755) + err := io.Local.EnsureDir(coreutil.JoinPath(tmpDir, "subdir")) require.NoError(t, err) // Create a valid template - err = os.WriteFile(filepath.Join(tmpDir, "valid.yml"), []byte("# Valid\nkernel:"), 0644) + err = io.Local.Write(coreutil.JoinPath(tmpDir, "valid.yml"), "# Valid\nkernel:") require.NoError(t, err) templates := scanUserTemplates(tmpDir) @@ -419,13 +420,13 @@ func TestScanUserTemplates_Good_SkipsDirectories(t *testing.T) { assert.Equal(t, "valid", templates[0].Name) } -func TestScanUserTemplates_Good_YamlExtension(t *testing.T) { +func TestScanUserTemplates_YamlExtension_Good(t *testing.T) { tmpDir := t.TempDir() // Create templates with both extensions - err := os.WriteFile(filepath.Join(tmpDir, "template1.yml"), []byte("# Template 1\nkernel:"), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "template1.yml"), "# Template 1\nkernel:") require.NoError(t, err) - err = os.WriteFile(filepath.Join(tmpDir, "template2.yaml"), []byte("# Template 2\nkernel:"), 0644) + err = io.Local.Write(coreutil.JoinPath(tmpDir, "template2.yaml"), "# Template 2\nkernel:") require.NoError(t, err) templates := scanUserTemplates(tmpDir) @@ -440,9 +441,9 @@ func TestScanUserTemplates_Good_YamlExtension(t *testing.T) { assert.True(t, names["template2"]) } -func TestExtractTemplateDescription_Good_EmptyComment(t *testing.T) { +func TestExtractTemplateDescription_EmptyComment_Good(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "test.yml") + path := coreutil.JoinPath(tmpDir, "test.yml") // First comment is empty, second has content content := `# @@ -450,7 +451,7 @@ func TestExtractTemplateDescription_Good_EmptyComment(t *testing.T) { kernel: image: test ` - err := os.WriteFile(path, []byte(content), 0644) + err := io.Local.Write(path, content) require.NoError(t, err) desc := extractTemplateDescription(path) @@ -458,9 +459,9 @@ kernel: assert.Equal(t, "Actual description here", desc) } -func TestExtractTemplateDescription_Good_MultipleEmptyComments(t *testing.T) { +func TestExtractTemplateDescription_MultipleEmptyComments_Good(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "test.yml") + path := coreutil.JoinPath(tmpDir, "test.yml") // Multiple empty comments before actual content content := `# @@ -470,7 +471,7 @@ func TestExtractTemplateDescription_Good_MultipleEmptyComments(t *testing.T) { kernel: image: test ` - err := os.WriteFile(path, []byte(content), 0644) + err := io.Local.Write(path, content) require.NoError(t, err) desc := extractTemplateDescription(path) @@ -478,14 +479,14 @@ kernel: assert.Equal(t, "Real description", desc) } -func TestScanUserTemplates_Good_DefaultDescription(t *testing.T) { +func TestScanUserTemplates_DefaultDescription_Good(t *testing.T) { tmpDir := t.TempDir() // Create a template without comments content := `kernel: image: test ` - err := os.WriteFile(filepath.Join(tmpDir, "nocomment.yml"), []byte(content), 0644) + err := io.Local.Write(coreutil.JoinPath(tmpDir, "nocomment.yml"), content) require.NoError(t, err) templates := scanUserTemplates(tmpDir)