Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infra/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@saga-ed/infra-compose",
"version": "1.3.1",
"version": "1.3.2",
"description": "Composable Docker service templates for Saga platform local development",
"bin": {
"infra-compose": "bin/infra-compose"
Expand Down
11 changes: 9 additions & 2 deletions infra/src/ec2/ec2-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -644,10 +644,16 @@ export function create_ec2_router(config = {}) {
function handle_switch(req, res, action) {
try {
const { name } = req.params;
const { profile } = req.body;
const { profile, seedFrom } = req.body;
if (!profile) {
return res.status(400).json({ ok: false, error: 'profile is required' });
}
// Optional source-override: restore this DB's `profile` from another
// DB's S3 prefix (the stable canonical template). Validated because it
// becomes an S3 key + `aws s3 cp` argument.
if (seedFrom !== undefined && !/^[a-zA-Z0-9_-]+$/.test(seedFrom)) {
return res.json({ ok: false, error: 'invalid seedFrom' });
}

const ports = get_allocated_ports({ registry_path });
const entry = ports[name];
Expand Down Expand Up @@ -679,6 +685,7 @@ export function create_ec2_router(config = {}) {
engine: entry.engine,
bucket: SEED_BUCKET,
seeds_base: SEEDS_BASE,
source_name: seedFrom,
});

// Regenerate compose with seeds (preserve original db_name)
Expand Down Expand Up @@ -717,7 +724,7 @@ export function create_ec2_router(config = {}) {
// Update profile registry
write_active_profile(name, profile, data_dir);

res.json({ ok: true, name, profile, action });
res.json({ ok: true, name, profile, action, source: seedFrom });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
Expand Down
9 changes: 7 additions & 2 deletions infra/src/ec2/profiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,18 @@ export function seed_after_start({ container, engine, seeds_dir, profile, db_use

// --- Download profile seed from S3 ---

export function download_profile_seed({ name, profile, engine, bucket, seeds_base }) {
export function download_profile_seed({ name, profile, engine, bucket, seeds_base, source_name }) {
const eng = engines[engine];
if (!eng) throw new Error(`Unknown engine: ${engine}`);

const ext = eng.seed_ext;
// The local seeds dir stays keyed by the TARGET db name; only the S3 source
// prefix is overridden when source_name (seedFrom) is provided, so a
// uniquely-named sandbox DB can restore a snapshot stored under a stable
// template name (e.g. programs-api-canonical). Used by both branches below.
const src = source_name || name;
const seeds_dir = resolve(seeds_base, name);
const s3_path = `s3://${bucket}/${name}/profile-${profile}.${ext}`;
const s3_path = `s3://${bucket}/${src}/profile-${profile}.${ext}`;

// Clear seeds dir
if (existsSync(seeds_dir)) {
Expand Down
73 changes: 73 additions & 0 deletions infra/test/unit/profiles-seedfrom.unit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Capture every spawnSync invocation so we can assert the `aws s3 cp` source path.
const spawnSync_calls = [];

vi.mock('child_process', () => ({
spawnSync: vi.fn((cmd, args) => {
spawnSync_calls.push([cmd, args]);
return { status: 0, stdout: '', stderr: '' };
}),
spawn: vi.fn(),
}));

// download_profile_seed touches the filesystem to (re)create the seeds dir; stub
// only the mutating calls, keep the rest real so nothing else breaks.
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
existsSync: vi.fn(() => false),
mkdirSync: vi.fn(),
readdirSync: vi.fn(() => []),
rmSync: vi.fn(),
writeFileSync: vi.fn(),
};
});

import { download_profile_seed } from '../../src/ec2/profiles.js';

/** The args array of the `aws s3 cp` invocation, if any. */
function awsCpArgs() {
const call = spawnSync_calls.find(
([cmd, args]) => cmd === 'aws' && args[0] === 's3' && args[1] === 'cp',
);
return call ? call[1] : null;
}

describe('download_profile_seed — seedFrom source-override', () => {
beforeEach(() => {
spawnSync_calls.length = 0;
});

it('reads from the DB own name when source_name is absent', () => {
download_profile_seed({
name: 'programs-api-sbx',
profile: 'canonical',
engine: 'postgres',
bucket: 'seeds-bkt',
seeds_base: '/tmp/seeds',
});
const args = awsCpArgs();
expect(args).not.toBeNull();
// args = ['s3', 'cp', <source>, <dest>]
expect(args[2]).toBe('s3://seeds-bkt/programs-api-sbx/profile-canonical.sql');
});

it('overrides ONLY the S3 source prefix when source_name (seedFrom) is provided', () => {
download_profile_seed({
name: 'programs-api-sbx',
profile: 'canonical',
engine: 'postgres',
bucket: 'seeds-bkt',
seeds_base: '/tmp/seeds',
source_name: 'programs-api-canonical',
});
const args = awsCpArgs();
expect(args).not.toBeNull();
// Source comes from the stable template name…
expect(args[2]).toBe('s3://seeds-bkt/programs-api-canonical/profile-canonical.sql');
// …but the local seed destination stays keyed by the TARGET db name.
expect(args[3]).toContain('/tmp/seeds/programs-api-sbx/');
});
});