|
root / src / profile.reef
profile.reef Reef 258 lines 9.4 KB
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
/******************************************************************************
                __               ____                __   
               / /   ___  ____ _/ __/_____________ _/ /__ 
              / /   / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \
             / /___/  __/ /_/ / __(__  ) /__/ /_/ / /  __/
            /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ 

    (C)opyright 2026, Leafscale, LLC -  https://www.leafscale.com

    Project: repoman
   Filename: src/profile.reef
    Authors: Chris Tusa <chris.tusa@leafscale.com>
    License: <see LICENSE file included with this source code>
Description: Profile library: vendor/user shadowing and ${HOST_LAN_IP}/${USER}/${HOME} substitution
     
******************************************************************************/

module profile

import core.str
import core.result_generic as rg
import core.convert as convert
import io.file as iofile
import io.dir as iodir
import sys.env
import paths
import incus

export
    type ProfileEntry
    type HostFacts
    fn vendor_dir(): string
    fn user_dir(home_dir: string): string
    fn render(yaml: string, host: HostFacts): string
    fn lookup(name: string, home_dir: string): rg.Result[string, string]
    fn list_all(home_dir: string): [ProfileEntry]
    fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string]
    fn remove_profile(name: string): rg.Result[bool, string]
    fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
    fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
end export

// One entry from the profile library, populated by list_all.
type ProfileEntry = struct
    name:      string         // e.g., "claude-share"
    source:    string         // "user", "vendor", or "user (shadows vendor)"
    file_path: string         // resolved file path
    installed: bool           // present in incus state
end ProfileEntry

// Substitution context for templated profile YAML.
type HostFacts = struct
    lan_ip: string
    user:   string
    home:   string
end HostFacts

// (function bodies follow in subsequent tasks)

fn vendor_dir(): string
    return "/usr/local/share/repoman/profiles"
end vendor_dir

fn user_dir(home_dir: string): string
    let suffix: string = ".config/repoman/profiles.d"
    if str.is_empty(home_dir)
        return "/" + suffix
    end if
    return paths.join(home_dir, suffix)
end user_dir

fn render(yaml: string, host: HostFacts): string
    let s1: string = str.replace(yaml, "\${HOST_LAN_IP}", host.lan_ip)
    let s2: string = str.replace(s1,   "\${USER}",        host.user)
    let s3: string = str.replace(s2,   "\${HOME}",        host.home)
    return s3
end render

fn lookup(name: string, home_dir: string): rg.Result[string, string]
    let user_path: string = paths.join(user_dir(home_dir), name + ".yml")
    if iofile.fileExists(user_path)
        return @Result[string, string].Ok(user_path)
    end if
    let vendor_path: string = paths.join(vendor_dir(), name + ".yml")
    if iofile.fileExists(vendor_path)
        return @Result[string, string].Ok(vendor_path)
    end if
    return @Result[string, string].Err("profile not found: " + name + " (looked in " + user_path + " and " + vendor_path + ")")
end lookup

fn list_all(home_dir: string): [ProfileEntry]
    // Buffers for filename lists. Cap at 256 per dir; profile libraries
    // are not expected to be enormous.
    let max_files: int = 256
    mut user_buf: [string] = new [string](max_files)
    mut vendor_buf: [string] = new [string](max_files)

    let u_dir: string = user_dir(home_dir)
    mut user_count: int = 0
    if iodir.dir_exists(u_dir)
        user_count = iodir.list_dir(u_dir, user_buf, max_files)
    end if

    let v_dir: string = vendor_dir()
    mut vendor_count: int = 0
    if iodir.dir_exists(v_dir)
        vendor_count = iodir.list_dir(v_dir, vendor_buf, max_files)
    end if

    // Pre-allocate result buffer at worst-case size.
    let cap: int = user_count + vendor_count
    mut entries_buf: [ProfileEntry] = new [ProfileEntry](cap)
    mut e_count: int = 0

    // Pass 1: add all user-dir *.yml files
    mut i: int = 0
    while i < user_count
        let fname: string = user_buf[i]
        if str.ends_with(fname, ".yml")
            let name: string = str.substring(fname, 0, str.length(fname) - 4)
            let path: string = paths.join(u_dir, fname)
            let installed_r = incus.profile_exists("default", name)
            mut installed: bool = false
            if rg.is_ok(installed_r)
                installed = rg.unwrap_ok(installed_r)
            end if
            entries_buf[e_count] = ProfileEntry {
                name:      name,
                source:    "user",
                file_path: path,
                installed: installed
            }
            e_count = e_count + 1
        end if
        i = i + 1
    end while

    // Pass 2: add vendor-dir *.yml files NOT already present (so user wins)
    mut j: int = 0
    while j < vendor_count
        let fname: string = vendor_buf[j]
        if str.ends_with(fname, ".yml")
            let name: string = str.substring(fname, 0, str.length(fname) - 4)
            // Check if name already in entries_buf
            mut shadowed: bool = false
            mut k: int = 0
            while k < e_count
                if entries_buf[k].name == name
                    shadowed = true
                    // Update the existing user entry's source label to indicate shadow
                    entries_buf[k] = ProfileEntry {
                        name:      entries_buf[k].name,
                        source:    "user (shadows vendor)",
                        file_path: entries_buf[k].file_path,
                        installed: entries_buf[k].installed
                    }
                end if
                k = k + 1
            end while
            if not shadowed
                let path: string = paths.join(v_dir, fname)
                let installed_r = incus.profile_exists("default", name)
                mut installed: bool = false
                if rg.is_ok(installed_r)
                    installed = rg.unwrap_ok(installed_r)
                end if
                entries_buf[e_count] = ProfileEntry {
                    name:      name,
                    source:    "vendor",
                    file_path: path,
                    installed: installed
                }
                e_count = e_count + 1
            end if
        end if
        j = j + 1
    end while

    // Compact to actual size
    mut entries: [ProfileEntry] = new [ProfileEntry](e_count)
    mut m: int = 0
    while m < e_count
        entries[m] = entries_buf[m]
        m = m + 1
    end while
    return entries
end list_all

fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string]
    // Resolve the file (user or vendor)
    let path_r = lookup(name, home_dir)
    if rg.is_err(path_r)
        return @Result[bool, string].Err(rg.unwrap_err(path_r))
    end if
    let path: string = rg.unwrap_ok(path_r)

    // Read the file
    let yaml: string = iofile.readFile(path)
    if str.length(yaml) == 0
        return @Result[bool, string].Err("empty or unreadable profile file: " + path)
    end if

    // Render — but if HOST_LAN_IP is needed and host.lan_ip is empty, fail with a hint
    if str.contains(yaml, "\${HOST_LAN_IP}") and str.length(host.lan_ip) == 0
        return @Result[bool, string].Err("profile " + name + " requires \${HOST_LAN_IP} but [host].lan_ip is empty in registry. Run 'repoman setup' to detect and store the host LAN IP.")
    end if
    let rendered: string = render(yaml, host)

    // Apply via incus
    return incus.profile_create_or_edit("default", name, rendered)
end install

fn remove_profile(name: string): rg.Result[bool, string]
    return incus.delete_profile("default", name)
end remove_profile

fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
    let path_r = lookup(name, home_dir)
    if rg.is_err(path_r)
        return @Result[string, string].Err(rg.unwrap_err(path_r))
    end if
    let path: string = rg.unwrap_ok(path_r)
    let yaml: string = iofile.readFile(path)
    let rendered: string = render(yaml, host)
    return @Result[string, string].Ok(rendered)
end show

fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
    // Render the file
    let path_r = lookup(name, home_dir)
    if rg.is_err(path_r)
        return @Result[string, string].Err(rg.unwrap_err(path_r))
    end if
    let path: string = rg.unwrap_ok(path_r)
    let yaml: string = iofile.readFile(path)
    let rendered: string = render(yaml, host)

    // Fetch incus state
    let incus_r = incus.profile_get("default", name)
    if rg.is_err(incus_r)
        // Profile not installed — diff shows the rendered file as "would install"
        let msg: string = "profile " + name + " is not installed. Rendered would-be content:\n\n" + rendered
        return @Result[string, string].Ok(msg)
    end if
    let live: string = rg.unwrap_ok(incus_r)

    if rendered == live
        return @Result[string, string].Ok("(no drift)")
    end if

    // Simple side-by-side: file content marked '-- file --', incus content '-- incus --'.
    let header: string = "--- profile file (rendered): " + path + "\n+++ incus state\n"
    let body: string = "-- file --\n" + rendered + "\n-- incus --\n" + live
    return @Result[string, string].Ok(header + body)
end diff

end module