package state import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "github.com/devantler-tech/ksail/v7/pkg/apis/cluster/v1alpha1" ) const ( // stateDir is the directory under the user's home where cluster state is stored. stateDir = ".ksail" // clustersSubDir holds per-cluster state directories. clustersSubDir = "clusters" // specFileName is the file containing the serialized ClusterSpec. specFileName = "spec.json" // dirPermissions is the permission mode for state directories. dirPermissions = 0o700 // filePermissions is the permission mode for state files. filePermissions = 0o600 ) // ErrStateNotFound is returned when no saved state exists for a cluster. var ErrStateNotFound = errors.New("cluster state not found") // ErrInvalidClusterName is returned when a cluster name contains path traversal characters. var ErrInvalidClusterName = errors.New( "invalid cluster name: must not contain path separators or '..'", ) // clusterStateDir returns the per-cluster state directory path for a given cluster name. // It validates the cluster name to prevent path traversal attacks. func clusterStateDir(clusterName string) (string, error) { if strings.Contains(clusterName, "/") || strings.Contains(clusterName, "\\") || strings.Contains(clusterName, "..") { return "", ErrInvalidClusterName } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to get user home directory: %w", err) } return filepath.Join(home, stateDir, clustersSubDir, clusterName), nil } // clusterStatePath returns the path to the state file for a given cluster name. func clusterStatePath(clusterName string) (string, error) { dir, err := clusterStateDir(clusterName) if err != nil { return "", err } return filepath.Join(dir, specFileName), nil } // SaveClusterSpec persists the ClusterSpec used during cluster creation. // This allows the update command to compare against the actual creation-time // configuration instead of static defaults. func SaveClusterSpec(clusterName string, spec *v1alpha1.ClusterSpec) error { statePath, err := clusterStatePath(clusterName) if err != nil { return err } dir := filepath.Dir(statePath) err = os.MkdirAll(dir, dirPermissions) if err != nil { return fmt.Errorf("failed to create state directory %s: %w", dir, err) } data, err := json.MarshalIndent(sanitizeSpecForPersistence(spec), "", " ") if err != nil { return fmt.Errorf("failed to marshal cluster spec: %w", err) } err = os.WriteFile(statePath, data, filePermissions) if err != nil { return fmt.Errorf("failed to write cluster state: %w", err) } return nil } // sanitizeSpecForPersistence returns a copy of spec that is safe to write to // disk. The persisted spec is only ever used as an update-diff baseline; it is // never a credential source (registry auth is always resolved at use-time from // the live config via LocalRegistry.ResolveCredentials). The resolved registry // password — e.g. an expanded ${GITHUB_TOKEN} / GHCR PAT — must therefore never // be written to ~/.ksail in cleartext, so it is masked here with the same // redaction the diff output uses. The work is done on a deep copy so the // caller's in-memory spec, which may still drive registry operations after the // save, is left untouched. func sanitizeSpecForPersistence(spec *v1alpha1.ClusterSpec) *v1alpha1.ClusterSpec { if spec == nil { return nil } sanitized := spec.DeepCopy() sanitized.LocalRegistry.Registry = v1alpha1.RedactRegistryCredentials( sanitized.LocalRegistry.Registry, ) return sanitized } // LoadClusterSpec loads a previously saved ClusterSpec for a cluster. // Returns ErrStateNotFound if no state exists for this cluster name. func LoadClusterSpec(clusterName string) (*v1alpha1.ClusterSpec, error) { statePath, err := clusterStatePath(clusterName) if err != nil { return nil, err } //nolint:gosec // path is constructed from user home + constant subpath, not user input data, err := os.ReadFile(statePath) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("%w: %s", ErrStateNotFound, clusterName) } return nil, fmt.Errorf("failed to read cluster state: %w", err) } var spec v1alpha1.ClusterSpec err = json.Unmarshal(data, &spec) if err != nil { return nil, fmt.Errorf("failed to unmarshal cluster spec: %w", err) } return &spec, nil } // DeleteClusterState removes the saved state for a cluster. // This should be called during cluster deletion to clean up. // Returns nil if the state does not exist (idempotent). func DeleteClusterState(clusterName string) error { dir, err := clusterStateDir(clusterName) if err != nil { return err } err = os.RemoveAll(dir) if err != nil { return fmt.Errorf("failed to remove cluster state directory: %w", err) } return nil }