@@ -615,6 +615,7 @@ func copyWorkspaceToVolume(cfg *config.Config, id, workspaceDir string, u *ui.UI
615615 u .Blank ()
616616 u .Infof ("Importing workspace from %s..." , workspaceDir )
617617
618+ ensureVolumeWritable (cfg , targetDir , u )
618619 if err := os .MkdirAll (targetDir , 0o755 ); err != nil {
619620 u .Warnf ("could not create workspace directory: %v" , err )
620621 return
@@ -706,6 +707,7 @@ func injectSkillsToVolume(cfg *config.Config, id string, deploymentDir string, u
706707 }
707708
708709 targetDir := skillsVolumePath (cfg , id )
710+ ensureVolumeWritable (cfg , targetDir , u )
709711 if err := os .MkdirAll (targetDir , 0o755 ); err != nil {
710712 u .Warnf ("could not create skills volume directory: %v" , err )
711713 return
@@ -732,54 +734,93 @@ func injectSkillsToVolume(cfg *config.Config, id string, deploymentDir string, u
732734 fixVolumeOwnership (cfg , targetDir , u )
733735}
734736
737+ // k3dNodeExec runs a shell command inside the k3d node container, translating
738+ // the host-side path to the in-node path (/data/…). Returns an error when
739+ // the command fails or the path is outside the data directory. This is the
740+ // shared core for fixVolumeOwnership and ensureVolumeWritable.
741+ func k3dNodeExec (cfg * config.Config , hostPath , shellCmd string ) error {
742+ stackID := ""
743+ if data , err := os .ReadFile (filepath .Join (cfg .ConfigDir , ".stack-id" )); err == nil {
744+ stackID = strings .TrimSpace (string (data ))
745+ }
746+ if stackID == "" {
747+ return fmt .Errorf ("stack ID not found" )
748+ }
749+
750+ container := fmt .Sprintf ("k3d-obol-stack-%s-server-0" , stackID )
751+
752+ // Convert host path to the in-node path. k3d mounts $DATA_DIR → /data.
753+ relPath , err := filepath .Rel (cfg .DataDir , hostPath )
754+ if err != nil {
755+ return fmt .Errorf ("cannot compute relative path from %s to %s: %w" , cfg .DataDir , hostPath , err )
756+ }
757+ if strings .HasPrefix (relPath , ".." ) {
758+ return fmt .Errorf ("path %s is not under DataDir %s" , hostPath , cfg .DataDir )
759+ }
760+ nodePath := filepath .Join ("/data" , relPath )
761+
762+ // Replace the placeholder with the shell-quoted node path.
763+ quoted := "'" + strings .ReplaceAll (nodePath , "'" , "'\" '\" '" ) + "'"
764+ expanded := strings .ReplaceAll (shellCmd , "{}" , quoted )
765+
766+ cmd := exec .Command ("docker" , "exec" , container , "sh" , "-c" , expanded )
767+ return cmd .Run ()
768+ }
769+
735770// fixVolumeOwnership normalises file ownership on a host-side PVC path so the
736- // container (UID 1000 / node) can read and write. On k3d the host path is
771+ // container (UID 1000 / node) can read and write. On k3d the host path is
737772// inside a Docker container (the k3d node), so we exec into it as root and
738- // chown recursively. On k3s the host IS the node, so we attempt a direct
773+ // chown recursively. On k3s the host IS the node, so we attempt a direct
739774// chown (works when the CLI runs as root, harmless no-op otherwise).
740775//
741776// The optional ui parameter enables user-visible warnings when chown fails.
742777// Pass nil when no UI context is available (e.g. GenerateWallet).
743778func fixVolumeOwnership (cfg * config.Config , hostPath string , u * ui.UI ) {
744- // Determine backend (default: k3d for backward compat).
745779 backendName := "k3d"
746780 if data , err := os .ReadFile (filepath .Join (cfg .ConfigDir , ".stack-backend" )); err == nil {
747781 backendName = strings .TrimSpace (string (data ))
748782 }
749783
750784 switch backendName {
751785 case "k3d" :
752- stackID := ""
753- if data , err := os .ReadFile (filepath .Join (cfg .ConfigDir , ".stack-id" )); err == nil {
754- stackID = strings .TrimSpace (string (data ))
755- }
756- if stackID == "" {
757- return
758- }
759- container := fmt .Sprintf ("k3d-obol-stack-%s-server-0" , stackID )
760-
761- // Convert host path to the in-node path. k3d mounts $DATA_DIR → /data.
762- relPath , err := filepath .Rel (cfg .DataDir , hostPath )
763- if err != nil {
764- u .Warnf ("fixVolumeOwnership: cannot compute relative path from %s to %s: %v" , cfg .DataDir , hostPath , err )
765- return
766- }
767- if strings .HasPrefix (relPath , ".." ) {
768- u .Warnf ("fixVolumeOwnership: path %s is not under DataDir %s, skipping" , hostPath , cfg .DataDir )
769- return
770- }
771- nodePath := filepath .Join ("/data" , relPath )
772-
773- cmd := exec .Command ("docker" , "exec" , container ,
774- "chown" , "-R" , "1000:1000" , nodePath )
775- if err := cmd .Run (); err != nil {
776- u .Warnf ("Failed to fix volume ownership for %s: %v" , nodePath , err )
786+ if err := k3dNodeExec (cfg , hostPath , "chown -R 1000:1000 {}" ); err != nil {
787+ if u != nil {
788+ u .Warnf ("Failed to fix volume ownership for %s: %v" , hostPath , err )
789+ }
777790 }
778791 default :
779792 // k3s — direct host, try chown (succeeds if root).
780793 cmd := exec .Command ("chown" , "-R" , "1000:1000" , hostPath )
781794 if err := cmd .Run (); err != nil {
782- u .Warnf ("Failed to fix volume ownership for %s: %v (expected if not root)" , hostPath , err )
795+ if u != nil {
796+ u .Warnf ("Failed to fix volume ownership for %s: %v (expected if not root)" , hostPath , err )
797+ }
798+ }
799+ }
800+ }
801+
802+ // ensureVolumeWritable pre-creates a host-side PVC directory and chowns it to
803+ // the current (host) user so subsequent host-side writes succeed. On k3d, the
804+ // local-path-provisioner creates directories as root inside the node container,
805+ // making them root-owned on the host. Best-effort: failures are logged but do
806+ // not block provisioning.
807+ func ensureVolumeWritable (cfg * config.Config , hostPath string , u * ui.UI ) {
808+ backendName := "k3d"
809+ if data , err := os .ReadFile (filepath .Join (cfg .ConfigDir , ".stack-backend" )); err == nil {
810+ backendName = strings .TrimSpace (string (data ))
811+ }
812+
813+ if backendName != "k3d" {
814+ return
815+ }
816+
817+ uid := os .Getuid ()
818+ gid := os .Getgid ()
819+ shellCmd := fmt .Sprintf ("mkdir -p {} && chown -R %d:%d {}" , uid , gid )
820+
821+ if err := k3dNodeExec (cfg , hostPath , shellCmd ); err != nil {
822+ if u != nil {
823+ u .Warnf ("Could not pre-create volume directory %s: %v" , hostPath , err )
783824 }
784825 }
785826}
@@ -1421,6 +1462,7 @@ func SkillsSync(cfg *config.Config, id, skillsDir string, u *ui.UI) error {
14211462
14221463 u .Infof ("Syncing skills from %s to volume..." , skillsDir )
14231464
1465+ ensureVolumeWritable (cfg , targetDir , u )
14241466 if err := os .MkdirAll (targetDir , 0o755 ); err != nil {
14251467 return fmt .Errorf ("failed to create skills volume directory: %w" , err )
14261468 }
0 commit comments