Developer-Dashboard
view release on metacpan or search on metacpan
t/10-extension-action-docker.t view on Meta::CPAN
use strict;
use warnings;
use Cwd qw(abs_path);
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempdir);
use Test::More;
use Time::HiRes qw(sleep);
use URI::Escape qw(uri_escape);
use lib 'lib';
use Developer::Dashboard::ActionRunner;
use Developer::Dashboard::Auth;
use Developer::Dashboard::Config;
use Developer::Dashboard::DockerCompose;
use Developer::Dashboard::FileRegistry;
use Developer::Dashboard::PageDocument;
use Developer::Dashboard::PageResolver;
use Developer::Dashboard::PageStore;
use Developer::Dashboard::PathRegistry;
use Developer::Dashboard::SessionStore;
use Developer::Dashboard::Web::App;
sub _portable_path {
my ($path) = @_;
return undef if !defined $path;
my $resolved = eval { abs_path($path) };
return defined $resolved && $resolved ne '' ? $resolved : $path;
}
sub is_same_path {
my ( $got, $expected, $label ) = @_;
is( _portable_path($got), _portable_path($expected), $label );
}
my $home = tempdir(CLEANUP => 1);
local $ENV{HOME} = $home;
local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
local $ENV{DEVELOPER_DASHBOARD_CHECKERS};
chdir $home or die "Unable to chdir to $home: $!";
my $repo = File::Spec->catdir( $home, 'projects', 'demo-app' );
make_path( File::Spec->catdir( $repo, '.git' ) );
open my $compose_fh, '>', File::Spec->catfile( $repo, 'compose.yaml' ) or die $!;
print {$compose_fh} "services:\n app:\n image: perl:latest\n";
close $compose_fh;
open my $override_fh, '>', File::Spec->catfile( $repo, 'compose.dev.yaml' ) or die $!;
print {$override_fh} "services:\n app:\n environment:\n MODE: dev\n";
close $override_fh;
open my $repo_cfg, '>', File::Spec->catfile( $repo, '.developer-dashboard.json' ) or die $!;
print {$repo_cfg} <<'JSON';
{
"docker": {
"project_overlays": ["compose.project.yaml"],
"services": {
"worker": {
"files": ["compose.worker.yaml"]
}
},
"addons": {
"mailhog": {
"files": ["compose.mailhog.yaml"],
"env": { "MAILHOG_ENABLED": "1" }
}
},
"modes": {
"dev": {
"files": ["compose.dev.yaml"],
"env": { "APP_MODE": "dev" }
}
}
},
"path_aliases": {
"repo_alias": "~/projects/demo-app",
"shared_alias": "~/projects/demo-app"
},
"providers": [
{
"id": "repo-provider",
"title": "Repo Provider",
"body": "from config provider"
},
{
"id": "shared-provider",
"page": {
"id": "shared-provider",
"title": "Shared Provider",
"layout": { "body": "from config page provider" },
"actions": [
{ "id": "show_state", "label": "Show State", "kind": "builtin", "builtin": "page.state", "safe": 1 }
]
}
}
]
}
JSON
close $repo_cfg;
open my $addon_fh, '>', File::Spec->catfile( $repo, 'compose.mailhog.yaml' ) or die $!;
print {$addon_fh} "services:\n mailhog:\n image: mailhog/mailhog\n";
close $addon_fh;
open my $project_overlay_fh, '>', File::Spec->catfile( $repo, 'compose.project.yaml' ) or die $!;
print {$project_overlay_fh} "services:\n app:\n environment:\n PROJECT_LAYER: 1\n";
close $project_overlay_fh;
open my $service_overlay_fh, '>', File::Spec->catfile( $repo, 'compose.worker.yaml' ) or die $!;
print {$service_overlay_fh} "services:\n worker:\n image: perl:latest\n";
close $service_overlay_fh;
my $global_docker_root = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker', 'green' );
make_path($global_docker_root);
open my $global_green_fh, '>', File::Spec->catfile( $global_docker_root, 'compose.yml' ) or die $!;
print {$global_green_fh} "services:\n green:\n extra_hosts:\n - host.docker.internal:host-gateway\n";
close $global_green_fh;
open my $global_green_dev_fh, '>', File::Spec->catfile( $global_docker_root, 'development.compose.yml' ) or die $!;
print {$global_green_dev_fh} "services:\n green:\n environment:\n GREEN_DEV: 1\n";
close $global_green_dev_fh;
my $global_blue_root = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker', 'blue' );
make_path($global_blue_root);
open my $global_blue_fh, '>', File::Spec->catfile( $global_blue_root, 'compose.yml' ) or die $!;
print {$global_blue_fh} "services:\n blue:\n image: alpine\n";
close $global_blue_fh;
open my $global_blue_disabled_fh, '>', File::Spec->catfile( $global_blue_root, 'disabled.yml' ) or die $!;
close $global_blue_disabled_fh;
my $global_purple_root = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker', 'purple' );
make_path($global_purple_root);
open my $global_purple_fh, '>', File::Spec->catfile( $global_purple_root, 'compose.yml' ) or die $!;
print {$global_purple_fh} "services:\n purple:\n image: alpine\n";
close $global_purple_fh;
my $skill_orange_root = File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'alpha-skill', 'config', 'docker', 'orange' );
make_path($skill_orange_root);
open my $skill_orange_fh, '>', File::Spec->catfile( $skill_orange_root, 'compose.yml' ) or die $!;
print {$skill_orange_fh} "services:\n orange:\n image: alpine\n";
close $skill_orange_fh;
open my $skill_orange_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'alpha-skill', '.env' ) or die $!;
print {$skill_orange_env_fh} "ORANGE_SKILL_ENV=orange-skill\nSKILL_ENV_REF=\${DOCKER_SKILL_BASE:-fallback}\n";
close $skill_orange_env_fh;
my $skill_green_root = File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'beta-skill', 'config', 'docker', 'green' );
make_path($skill_green_root);
open my $skill_green_fh, '>', File::Spec->catfile( $skill_green_root, 'compose.yml' ) or die $!;
print {$skill_green_fh} "services:\n green:\n environment:\n GREEN_DEV: skill\n";
close $skill_green_fh;
open my $skill_green_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'beta-skill', '.env' ) or die $!;
print {$skill_green_env_fh} "DISABLED_SKILL_ENV=beta-skill\n";
close $skill_green_env_fh;
open my $skill_green_disabled_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'beta-skill', '.disabled' ) or die $!;
close $skill_green_disabled_fh;
my $nested_skill_leaf_root = File::Spec->catdir(
$home, '.developer-dashboard', 'skills', 'foo', 'skills', 'bar', 'skills', 'zzz'
);
my $nested_skill_compose_root = File::Spec->catdir( $nested_skill_leaf_root, 'config', 'docker', 'zzz' );
make_path($nested_skill_compose_root);
open my $nested_skill_compose_fh, '>', File::Spec->catfile( $nested_skill_compose_root, 'compose.yml' ) or die $!;
print {$nested_skill_compose_fh} "services:\n zzz:\n image: alpine\n";
close $nested_skill_compose_fh;
open my $nested_skill_root_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'foo', '.env' ) or die $!;
print {$nested_skill_root_env_fh} "VERSION=foo\nSHARED_CHAIN=root\n";
close $nested_skill_root_env_fh;
open my $nested_skill_parent_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'foo', 'skills', 'bar', '.env' ) or die $!;
print {$nested_skill_parent_env_fh} "VERSION=bar\nSHARED_CHAIN=bar\n";
close $nested_skill_parent_env_fh;
open my $nested_skill_leaf_env_fh, '>', File::Spec->catfile( $nested_skill_leaf_root, '.env' ) or die $!;
print {$nested_skill_leaf_env_fh} "VERSION=zzz\nSHARED_CHAIN=zzz\n";
close $nested_skill_leaf_env_fh;
my $local_docker_green_root = File::Spec->catdir( $repo, '.developer-dashboard', 'config', 'docker', 'green' );
make_path($local_docker_green_root);
open my $local_green_dev_fh, '>', File::Spec->catfile( $local_docker_green_root, 'development.compose.yml' ) or die $!;
print {$local_green_dev_fh} "services:\n green:\n environment:\n GREEN_DEV: local\n";
close $local_green_dev_fh;
t/10-extension-action-docker.t view on Meta::CPAN
);
};
like( $@, qr/Unsupported builtin action/, 'unsupported builtin actions are rejected' );
my $transient_allowed = Developer::Dashboard::PageDocument->new(
title => 'Transient Allowed',
actions => [
{ id => 'run', label => 'Run', kind => 'command', command => 'printf allowed' },
],
permissions => {
allow_untrusted_actions => 1,
trusted_actions => ['run'],
},
);
my $allowed_page = $transient_allowed;
my $allowed_result = $actions->run_page_action(
action => { id => 'run', label => 'Run', kind => 'command', command => 'printf allowed' },
page => $allowed_page,
source => 'transient',
);
like( $allowed_result->{stdout}, qr/allowed/, 'transient encoded page can opt in specific trusted actions' );
{
my $old = Cwd::getcwd();
chdir $repo or die $!;
my $docker = Developer::Dashboard::DockerCompose->new(
config => $config,
paths => $paths,
);
local $ENV{DOCKER_SKILL_BASE} = 'skill-base';
local $ENV{DD_TEST_DOCKER_ROOT} = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker' );
is(
$docker->_expand_env_path('${DD_TEST_DOCKER_ROOT}/green/compose.yml'),
File::Spec->catfile( $ENV{DD_TEST_DOCKER_ROOT}, 'green', 'compose.yml' ),
'docker compose resolver expands defined braced environment variables in configured compose paths',
);
is(
$docker->_expand_env_path('${DD_TEST_DOCKER_MISSING}green/compose.yml'),
'green/compose.yml',
'docker compose resolver collapses undefined braced environment variables in configured compose paths',
);
is(
$docker->_expand_env_path('$DD_TEST_DOCKER_ROOT/green/compose.yml'),
File::Spec->catfile( $ENV{DD_TEST_DOCKER_ROOT}, 'green', 'compose.yml' ),
'docker compose resolver expands defined bare environment variables in configured compose paths',
);
is(
$docker->_expand_env_path('$DD_TEST_DOCKER_MISSING' . 'green/compose.yml'),
'/compose.yml',
'docker compose resolver collapses undefined bare environment variables in configured compose paths',
);
my $resolved = $docker->resolve(
addons => [ 'mailhog' ],
args => [ 'config', 'green' ],
modes => ['dev'],
services => [ 'worker', 'orange' ],
);
chdir $old or die $!;
is_same_path( $resolved->{project_root}, $repo, 'docker compose resolver uses current project root' );
ok( grep( { /compose\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes discovered base file' );
ok( grep( { /compose\.project\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes project overlay' );
ok( grep( { /compose\.worker\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes service overlay' );
ok( grep( { /compose\.dev\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes mode overlay' );
ok( grep( { /compose\.mailhog\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes config addon overlay' );
ok( grep( { /green\/development\.compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes isolated development compose files automatically for selected services' );
ok( !grep( { /skills\/beta-skill\/config\/docker\/green\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver excludes docker roots contributed by disabled skills' );
is( $resolved->{env}{APP_MODE}, 'dev', 'docker compose resolver merges mode env' );
is_same_path(
$resolved->{env}{alpha_skill_DDDC},
File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'alpha-skill', 'config', 'docker' ),
'docker compose resolver exports one skill-specific DDDC variable for each participating skill docker root',
);
is( $resolved->{env}{ORANGE_SKILL_ENV}, 'orange-skill', 'docker compose resolver loads .env from participating skill docker roots' );
is( $resolved->{env}{SKILL_ENV_REF}, 'skill-base', 'docker compose resolver expands participating skill .env values against the current process environment' );
ok( !exists $resolved->{env}{DISABLED_SKILL_ENV}, 'docker compose resolver skips .env from disabled skill docker roots' );
ok( grep( { /skills\/alpha-skill\/\.env$/ } @{ $resolved->{env_files} } ), 'docker compose resolver reports participating skill .env files in dry-run data' );
is_same_path( $resolved->{env}{DDDC}, File::Spec->catdir( $repo, '.developer-dashboard', 'config', 'docker' ), 'docker compose resolver exports DDDC as the effective project-local docker config root' );
is( $resolved->{env}{MAILHOG_ENABLED}, '1', 'docker compose resolver merges addon env' );
is_deeply( [ @{ $resolved->{command} }[0,1] ], [ 'docker', 'compose' ], 'docker compose resolver produces docker compose command' );
is_deeply( $resolved->{precedence}, [ qw(base project service addon mode) ], 'docker compose resolver exposes overlay precedence' );
is_same_path(
( grep { /green\/development\.compose\.yml$/ } @{ $resolved->{files} } )[-1],
File::Spec->catfile( $repo, '.developer-dashboard', 'config', 'docker', 'green', 'development.compose.yml' ),
'docker compose resolver leaves the deepest project-local config/docker service folder as the last overriding development compose layer',
);
ok( grep( { $_ eq 'green' } @{ $resolved->{services} } ), 'docker compose resolver infers service names from passthrough docker compose args' );
is( $resolved->{command}[-1], 'green', 'docker compose resolver preserves passthrough docker compose service args' );
}
{
my $old = Cwd::getcwd();
chdir $repo or die $!;
my $docker = Developer::Dashboard::DockerCompose->new(
config => $config,
paths => $paths,
);
local $ENV{ORANGE_SKILL_ENV};
my $resolved = $docker->resolve(
args => ['config'],
);
chdir $old or die $!;
ok( grep( { $_ eq 'green' } @{ $resolved->{services} } ), 'docker compose resolver auto-loads isolated services by default when no service is specified' );
ok( grep( { $_ eq 'orange' } @{ $resolved->{services} } ), 'docker compose resolver auto-loads isolated services contributed by installed skills' );
ok( grep( { $_ eq 'purple' } @{ $resolved->{services} } ), 'docker compose resolver auto-loads isolated services without requiring activation markers' );
ok( !grep( { $_ eq 'blue' } @{ $resolved->{services} } ), 'docker compose resolver skips isolated services marked disabled' );
ok( grep( { /green\/development\.compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes isolated development compose files during plain docker compose passthrough' );
ok( !grep( { /skills\/beta-skill\/config\/docker\/green\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose passthrough excludes compose roots contributed by disabled skills' );
ok( grep( { /skills\/alpha-skill\/config\/docker\/orange\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes skill docker compose roots during plain docker compose passthrough' );
ok( grep( { /purple\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes non-disabled isolated compose folders during plain docker compose passthrough' );
ok( !grep( { /blue\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver does not include disabled isolated compose folders' );
is_same_path(
$resolved->{env}{alpha_skill_DDDC},
File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'alpha-skill', 'config', 'docker' ),
'docker compose resolver auto-loads one skill-specific DDDC variable for auto-discovered skill services',
);
is( $resolved->{env}{ORANGE_SKILL_ENV}, 'orange-skill', 'docker compose resolver auto-loads participating skill .env values alongside auto-discovered services' );
ok( !exists $resolved->{env}{DISABLED_SKILL_ENV}, 'docker compose resolver still skips disabled skill .env values during auto-discovery' );
ok( !defined $ENV{ORANGE_SKILL_ENV}, 'docker compose resolver does not leak participating skill .env values into the caller environment' );
is( $resolved->{command}[-1], 'config', 'docker compose resolver preserves passthrough config invocation with active auto-discovery' );
}
{
my $old = Cwd::getcwd();
chdir $repo or die $!;
my $docker = Developer::Dashboard::DockerCompose->new(
config => $config,
paths => $paths,
);
my $disabled = $docker->disable_service( service => 'green' );
my $enabled = $docker->enable_service( service => 'green' );
chdir $old or die $!;
is_same_path(
$disabled->{marker},
File::Spec->catfile( $repo, '.developer-dashboard', 'config', 'docker', 'green', 'disabled.yml' ),
'docker compose disable writes the project-local marker under config/docker',
);
is_same_path(
$enabled->{marker},
File::Spec->catfile( $repo, '.developer-dashboard', 'config', 'docker', 'green', 'disabled.yml' ),
'docker compose enable removes the project-local marker under config/docker',
( run in 0.916 second using v1.01-cache-2.11-cpan-df04353d9ac )